Table of Contents
Customer Development Portal
Welcome to the customer development portal.
This portal gives you access to the documentation and trust material for artifacts Merkalis publishes for you.
System Overview
The System Overview describes how Kastoria Health is built and what it provides: Kastoria Core, the architecture and how a study moves through it, and the features for DICOMweb, study processing, event notification, SMART on FHIR launch, the Admin Console, telemetry, and security. Start here if you are evaluating Kastoria Health.
Distribution Registry
The Distribution Registry guide explains how to:
- Authenticate to the registry with your scoped token
- Trust Merkalis's signing certificate
- Pull and verify signed container images
- Use signed OpenTofu modules
- Move artifacts into air-gapped environments
Download the trust material:
Docker Images
Docker Images documents each published Kastoria container image: its runtime details, OCI labels, how to pull and verify it, and its environment variables. The Configuration page describes the configuration document shared by the images that read or write stored data.
Terraform/Tofu Modules
Terraform/Tofu Modules documents the signed OpenTofu modules that compose a Kastoria environment on Azure — networking, secrets, object store, file store, queue, compute, gateway, and telemetry — along with how they depend on one another and a sample deployment that wires them together.
REST APIs
REST APIs documents the Kastoria Health service interfaces: DICOMweb STOW-RS, WADO-RS and QIDO-RS, SMART on FHIR launch, and the JWT authorization that restricts query and retrieval to a launched study.
Integration
Integration documents how downstream systems integrate with Kastoria Health beyond its REST APIs, starting with the Ingestion Manifest: the Notification Event published to a queue each time a study is processed, and how a consumer reads and applies it.
Conformance
The DICOM Conformance Statement details the DICOMweb Studies Service support in Kastoria Health: supported transactions, parameters, status codes, transfer syntaxes, and known limitations.
Release Notes
Release Notes summarize what each release contains, the standards and platform it supports, and behavior to be aware of. The current release is v0.9.1.
View Offline
This portal works without a network connection. Three ways to take it with you:
- Print to PDF — open the printable version of the entire portal (all pages, in reading order, with working internal links), then use your browser's Print dialog and choose Save as PDF.
- Download a single HTML page — open the printable version and use your browser's Save Page As with Webpage, Complete to save one HTML file, stylesheets, and images into a local folder you can open anytime.
- Install as an app — on first visit your browser offers to install this portal as an application (in Chrome/Edge: the install icon at the right end of the address bar; on Android/iOS: Add to Home Screen). Once loaded while online, every page is cached on your device and the entire portal remains available fully offline.
Kastoria Health System Overview
Kastoria Health is the control plane for medical imaging. It receives DICOM imaging studies, stores them in versioned, content-addressed storage, prepares them for search and retrieval, and makes them available to viewers, EHRs and downstream systems through open standards: DICOMweb, SMART on FHIR, and event notifications delivered to a queue.
This document describes, at a high level, how Kastoria Health is built and what it provides as of release v0.9.1. It covers:
- Kastoria Core, the data plane for Kastoria Health;
- the architecture of Kastoria Health and how a study moves through it; and
- the features Kastoria Health provides, together with how the system is operated, observed and secured.
It does not describe APIs, configuration or deployment steps. Those are covered by the REST API, integration and deployment documentation.
Audience
This document is intended for CIOs, CTOs, security reviewers, enterprise architects, and senior developers evaluating, deploying, or integrating Kastoria Health.
Kastoria Core
Kastoria Core is the data plane for Kastoria Health. Every study, every piece of derived data, and every configuration record Kastoria Health keeps is held in Kastoria Core.
Most systems store data by location: a file at a path, or a row in a table, whose contents can be changed in place. Kastoria Core stores data by content. Each piece of data is identified by a cryptographic fingerprint of its own bytes, and larger structures, such as an imaging study, are built by linking those pieces together into a Merkle tree. The fingerprint of the top of the tree identifies everything beneath it.
Why Merkle tree storage
- Immutability. Stored content is never edited in place. A change produces new content with a new identifier, and the earlier content remains. Every earlier version of a record is kept and can be read, so the system retains a complete history rather than only the latest state.
- Cryptographic integrity. Content is identified by its SHA-256 hash. Any change to the bytes, no matter how small, produces a different identifier, and each version of a record is linked to the version before it by that version's hash. The structure is therefore tamper-evident: content cannot be altered while keeping its identity, and history cannot be rewritten without breaking the chain of hashes.
- Deduplication. Identical content always has the same identifier, so it is stored once. When a study is re-sent, or a new version of a study shares most of its images with the previous version, the unchanged content is not stored again.
- Safe concurrency. Because content never changes, many readers and writers can work at once without locking. The only thing that changes is which version a name points to, and that change is made with a conditional write: two writers can never silently overwrite each other.
- Safe retries. Storing the same content twice produces the same result as storing it once. An interrupted operation can simply be repeated.
Primary components
Kastoria Core is made up of four primary components.
Block Storage
A block is a unit of stored content, addressed by its content identifier (CID): a self-describing identifier derived from the SHA-256 hash of the block's bytes. Blocks hold either structured data or raw binary data, such as the pixel data of a single image frame, and blocks refer to one another by CID to form larger structures.
Block Storage is where all content lives. Because a block's identifier is derived from its content, Block Storage is naturally deduplicated, and blocks can be spread evenly across multiple storage accounts to scale capacity and throughput. In an Azure deployment, blocks are held in Azure Storage.
Named Roots
Content-addressed data needs stable names. A patient's imaging study has to be found by its Study Instance UID, not by a hash that changes every time an image is added.
A Named Root is a durable, named pointer to the top of a Merkle tree. Its name never changes; the content it points to does. Each time a Named Root is updated, Kastoria Core records a new version that links to the version before it, so a Named Root carries its full, hash-linked version history.
Named Roots provide:
- Stable identity for records whose content evolves, such as a study that receives additional series over time.
- Complete version history, from which any earlier state of the record can be read.
- Conflict-free updates, since an update is applied only if the record is still in the state the writer last read. A writer that loses a race sees the winner's content and decides again, rather than overwriting it.
- Recoverable removal. Removing a Named Root is recorded as a new version, not a deletion, so a removal can be undone and its history is never lost.
Changelog
The Changelog is an append-only, time-ordered record of every change made to Named Roots: which record changed, when, and the content it moved from and to.
The Changelog is what lets the rest of the system react to change. Rather than being called directly when a study is stored, downstream components follow the Changelog and act on what they find there. This keeps each step of the system independent: a component that is paused, slow or restarted resumes from where it left off and misses nothing. The Changelog also provides a durable history of when each record changed, which supports troubleshooting and audit.
Ranged Indexes
Named Roots find a record by its name. Many questions are not asked by name: every study for a patient, or every study performed between two dates.
A Ranged Index is a named, persistent index that maps search keys to content, kept in key order. It answers exact lookups, range queries (for example, from one date to another) and prefix queries (for example, every name beginning with a given family name), and returns its results in order, a page at a time, without reading the records themselves.
Ranged Indexes are what make search fast at scale. Kastoria Health uses them to answer DICOMweb queries, such as searches by patient, accession number or study date, and to find the study an EHR asks to launch. Unlike Named Roots, a Ranged Index is not versioned: it always reflects the current content, and Kastoria Health keeps it up to date as studies are processed.
Kastoria Health Architecture
Kastoria Health is a set of containerized services deployed in the customer's own environment. All data remains in that environment.
flowchart LR
modalities["PACS / modalities /<br/>migration tools"]
ehr["EHR"]
operators["Operators"]
consumer["Downstream<br/>systems"]
subgraph kh["Kastoria Health (customer environment)"]
subgraph services["Services"]
gateway["Gateway"]
dicomweb["DICOMweb Service"]
viewer["Web Viewer"]
smart["SMART Launch Service"]
console["Admin Console"]
processor["Study Processor<br/>(scheduled)"]
events["Event Notification<br/>(scheduled)"]
end
core[("Kastoria Core<br/>Block Storage · Named Roots ·<br/>Changelog · Ranged Indexes")]
queue[["Notification<br/>Queue"]]
collector["Telemetry<br/>Collector"]
end
grafana["Grafana"]
modalities -- "STOW-RS" --> gateway
ehr -- "SMART launch" --> gateway
operators --> gateway
gateway --> dicomweb
gateway --> smart
gateway --> viewer
gateway --> console
viewer -- "QIDO-RS / WADO-RS" --> dicomweb
dicomweb --> core
smart --> core
console --> core
processor -- "follows Changelog" --> core
events -- "follows Changelog" --> core
events --> queue
queue --> consumer
services -. "traces & metrics" .-> collector
collector -.-> grafana
Components
| Component | Role |
|---|---|
| Gateway | The single entry point. Routes requests to each service and, in a mutual-TLS configuration, admits only callers presenting a trusted client certificate, granting each certificate access to specific services. |
| DICOMweb Service | Receives studies (STOW-RS), answers queries (QIDO-RS) and serves images and metadata (WADO-RS). |
| Study Processor | A scheduled job that prepares newly stored studies for query and retrieval. |
| Event Notification | A scheduled job that publishes a Notification Event to a queue each time a study is processed. |
| SMART Launch Service | Brokers SMART on FHIR launches from an EHR into the viewer. |
| Web Viewer | A zero-footprint, browser-based diagnostic viewer built on the open-source OHIF Viewer. |
| Admin Console | A web application for operating the system. |
| Kastoria Core | Versioned, content-addressed storage for all data. |
| Telemetry Collector | Receives traces and metrics from every service and forwards them to Grafana. |
The services and scheduled jobs are delivered as signed container images; see Docker Images.
The services that answer requests (DICOMweb, SMART Launch, the viewer and the gateway) keep no state of their own between requests; all shared state is in Kastoria Core. They scale horizontally by adding replicas.
The Study Processor and Event Notification run as scheduled jobs. Each run picks up where the last one finished, and only one run of each happens at a time, so overlapping schedules are harmless.
How a study moves through the system
A study passes through the ingestion pipeline, a sequence of steps that each scale independently:
- Store. A client sends DICOM instances to the DICOMweb Service. The instances are committed to Kastoria Core as a new version of the study, and the change is recorded in the Changelog.
- Process. The Study Processor finds the change in the Changelog and prepares the study for use: it builds the study's search indexes and summary, records the study's patient as a FHIR Patient resource, and assigns the study its FHIR ImagingStudy id.
- Notify. Event Notification finds the processed study and publishes a Notification Event, carrying the study's Ingestion Manifest, to the customer's queue.
A study is ingested once it has been processed. From then on it can be found with QIDO-RS, retrieved with WADO-RS, opened in the viewer, and launched from an EHR.
Features
DICOMweb
Kastoria Health implements the DICOMweb standard (DICOM PS3.18) over HTTPS. The full detail of what is supported is given in the DICOM Conformance Statement and the DICOMweb API documentation.
Study ingestion (STOW-RS)
- High-throughput ingestion of one or many DICOM instances per request, across one or many studies.
- Incremental studies. Instances and series sent for an existing study are added to it. Each store creates a new version of the study, and every earlier version is kept.
- Deduplicated storage. Each image frame is stored as its own content-addressed block, so content that has already been stored, such as a re-sent instance, is not stored again.
- Lossless compression on ingest. Images from modalities such as CT, MR, CR, DX and MG are stored in High-Throughput JPEG 2000 (HTJ2K) lossless format, which reduces storage and lets viewers display images progressively. Compression can be turned off for an individual request.
- Per-instance results. The response reports which instances were stored and which failed, and why. Rejected instances are also recorded in an ingestion error log, which operators can search in the Admin Console by study, request correlation id, or time.
- Concurrent senders. Many clients can send instances for the same study at the same time without overwriting one another's work.
See STOW-RS.
Query (QIDO-RS)
- Study search by patient name, Patient ID, accession number, study date and time (including date ranges), study description, modalities in the study, and Study Instance UID.
- Series search within a study, by modality, Series Instance UID and series number.
- Case-insensitive matching for clinical text such as patient names and accession numbers.
- Paged results, newest study first.
See QIDO-RS.
Image retrieval (WADO-RS)
- Retrieval of whole studies, series or instances.
- Metadata at study, series and instance level, in DICOM JSON.
- Individual frames, for efficient streaming to viewers.
- Rendered images and thumbnails in consumer formats such as JPEG and PNG, with windowing and sizing.
- On-the-fly transcoding. A client can request instances or frames in the transfer syntax it supports, including uncompressed, RLE, JPEG, JPEG-LS, JPEG 2000, HTJ2K and JPEG XL.
See WADO-RS.
Study processing
Stored studies are prepared for use by the Study Processor, which runs on a schedule and follows the Changelog to find every study that has changed.
- Search indexes and study summaries are built for each study, so queries are answered from indexes rather than by scanning studies.
- FHIR identity. Each study with a Patient ID has a FHIR R4 Patient resource recorded for its patient, and each study is assigned a FHIR ImagingStudy id. The ImagingStudy id is assigned when the study is first processed and does not change; it is the id an EHR uses to launch the study.
- Always the latest version. When a study changes several times before it is processed, it is processed once, at its latest version.
- Idempotent. Processing a study that has not changed produces no new data and no new events.
- Automatic recovery from transient failures. A study that fails because of a temporary condition, such as a storage timeout, is retried automatically.
- Permanent failures are surfaced, not retried endlessly. A study whose content cannot be processed is recorded as a processing failure, visible in the Admin Console. It is processed again when a new version of the study arrives or when an operator requests it.
- Reprocessing on request. Operators can ask for a single study, or every study ingested within a time window, to be processed again on the next run.
- Run history. Every run that does work is recorded, with its outcome and the number of studies it processed, failed or skipped.
Event notification and the Ingestion Manifest
Kastoria Health tells downstream systems about the studies it has processed. Each time a study is processed, Event Notification publishes a Notification Event to a queue in the customer's own environment.
- The Ingestion Manifest. Each event carries the study's Ingestion Manifest: the DICOM identifiers and patient and study attributes a third party needs to map the study between systems, together with the FHIR Patient and ImagingStudy ids Kastoria Health holds for it.
- Complete, self-contained events. Every event carries the whole manifest as it stands after the change, so a consumer never needs to call Kastoria Health back.
- Created and updated events. A study's first processing publishes a
createdevent; each later change publishes anupdatedevent. - At-least-once delivery. Events are published durably and never expire on the queue. A consumer applies a simple ordering rule that makes duplicates and out-of-order delivery harmless.
- Secretless access. Kastoria Health publishes, and consumers read, using Microsoft Entra ID identities. No shared keys or SAS tokens are issued.
- Re-publishing. If a consumer loses events, an operator can re-publish every event from a chosen point in time from the Admin Console.
The event format, delivery guarantees and consumer guidance are specified in Ingestion Manifest.
SMART on FHIR launch
Kastoria Health lets clinicians open imaging studies directly from their EHR using the SMART App Launch standard (EHR launch, v2.2).
- In-context launch. The EHR launches Kastoria Health with the patient and the imaging study in context. Kastoria Health authorizes with the EHR, confirms that the study belongs to the patient in context, and opens the study in the viewer.
- Registered EHRs only. Each EHR is registered in the Admin Console with its own client configuration. Launches from an unregistered or deactivated EHR are refused, and an EHR can be deactivated at any time without redeploying.
- Standards-based security. Every authorization uses PKCE, and the EHR's identity token is verified against the EHR's signing keys whenever the EHR publishes them.
- Stateless and scalable. The state of an in-progress launch is carried in a short-lived encrypted, tamper-proof token, keyed per EHR. Nothing is held on the server between steps, so the service scales horizontally.
- Study-scoped viewer access. A launch can issue the viewer a short-lived signed token that authorizes it to query and retrieve only the launched study. Query and retrieval requests without a valid token, or for a study outside the token's scope, are refused. See JWT Authorization.
- Clear error pages. A launch that cannot complete ends on an error page that names what went wrong, without exposing patient data.
See SMART on FHIR — smart-launch-api.
Admin Console
The Admin Console is a web application for operating Kastoria Health.
- SMART launch configuration. Register, edit, activate, deactivate and remove the EHRs permitted to launch Kastoria Health.
- Content Explorer. Search stored studies, view a study's series and its FHIR Patient and ImagingStudy ids, open a study in the viewer, and request that a study be reprocessed.
- Study Ingestion. Search the ingestion error log for instances that could not be stored, and inspect each error.
- Study Processing. Monitor the Study Processor at a glance: whether it is running, the studies waiting to be processed, processing failures and reprocess requests. Review run history, reprocess or dismiss failures, and request reprocessing for a single study or a time window.
- Events. Monitor event publishing and pending events, and re-publish events from a point in time.
- Storage Explorer. Browse the Kastoria Core Changelog by record type, time window and kind of change, and inspect the stored records it refers to.
Access to the Admin Console is controlled at the network layer, through the gateway.
Telemetry and monitoring
Every Kastoria Health service is instrumented with OpenTelemetry, the open industry standard for observability.
- Traces follow each request through the services and into storage, showing where time is spent.
- Metrics record request rates, errors and latency for every route, together with service-specific measures such as instances stored, search results returned, processing runs and failures, and events published.
- Logs are written by every service and collected by Azure Monitor (Log Analytics).
- No PHI in DICOMweb or storage telemetry. Patient identifiers and study UIDs are not used as metric labels or trace attributes, and URL paths and query strings are scrubbed of patient data before they are recorded.
Telemetry is sent to an OpenTelemetry collector deployed inside the customer's environment. The collector can forward traces, metrics and logs to Grafana Cloud, or telemetry can be kept entirely within the environment. When Grafana is used, Azure Monitor is also connected as a Grafana data source. See the Azure Telemetry module.
Kastoria Health provides ready-made Grafana dashboards:
| Dashboard | Shows |
|---|---|
| Services Overview | Request rate, error rate and latency across all services, with the slowest routes. |
| DICOMweb Performance | Request rate, errors and latency broken down by STOW-RS, QIDO-RS and WADO-RS, with latency distributions, abandoned searches, and links to traces. |
| Admin Console Performance | Request rate, errors and latency for the Admin Console by area. |
| Performance Test | Results of Kastoria Health's load and regression test suite: latency, errors, throughput and concurrent users by scenario. |
Security
- Customer-controlled deployment. Kastoria Health is deployed in the customer's own environment. All imaging data, derived data and telemetry stays in the customer's environment.
- Network isolation. Services run on a private virtual network.
- Mutual TLS at the gateway. The mutual-TLS gateway admits only callers presenting a trusted client certificate, and grants each certificate access only to the services assigned to it. Callers with no assigned access are denied. See kastoria-proxy-mtls.
- Encryption. Data is encrypted at rest by Azure Storage, and all traffic to storage uses TLS 1.2 or later.
- No stored credentials. Services reach storage, queues and the key vault with Azure managed identities, so the storage configuration carries no secret material (see Configuration). Secrets referenced from Azure Key Vault never appear in deployment state.
- Tamper-evident storage. All data is content-addressed and versioned in Kastoria Core, as described above.
- Signed, scanned software. Every container image and deployment module Merkalis delivers is cryptographically signed and can be verified before deployment. Images run as non-privileged users, are scanned for vulnerabilities during the build, cannot be released with a known critical or high severity vulnerability, and carry SLSA provenance and a software bill of materials (SBOM). See Distribution Registry for verifying signatures.
Merkalis Distribution Registry
Customer-facing distribution registry for promoted, signed release artifacts:
- Container images promoted from the internal build registry and tagged with a customer-facing
semver (e.g.
<customer>/kastoria-testcontainer:v0.0.1) - OpenTofu module packages published under
modules/<env>/<component>(e.g.modules/azure/compute:v0.0.1)
Registry login server:
acrmerkalisdist0c66.azurecr.io
Every artifact is signed with Notation (Notary v2) using an HSM-backed certificate in Azure Key Vault. The trust material to verify those signatures is published in this directory:
| File | Purpose | Download |
|---|---|---|
trust-policy.json |
Notation trust policy to import with notation policy import |
Download |
merkalis-dist-ca.pem |
Public root certificate of the AKV signing cert (trust anchor) | Download |
This material is versioned alongside the modules you consume: update it whenever a new module version is published.
Prerequisites
- Docker (or another OCI client such as oras)
- Notation CLI 1.3.x
- OpenTofu >= 1.10 (only needed for module consumption)
- Your registry credentials (below)
1. Get your credentials
Access is granted with a scoped token minted for your customer namespace:
- Username:
token-<customer> - Password: issued to you by Merkalis. It is stored in the audited Key Vault secret
acr-token-<customer>and is the only credential you need.
The token only allows read access to the repositories you are entitled to (your image
namespace plus the whole modules/azure/* module namespace). It cannot write or delete anything.
2. Authenticate to the registry
Log in with the scoped token. Docker writes the credential to ~/.docker/config.json, which is
also what OpenTofu reads for OCI module sources:
docker login acrmerkalisdist0c66.azurecr.io
# Username: token-<customer>
# Password: <password issued by Merkalis>
If you use oras, log in the same way:
oras login acrmerkalisdist0c66.azurecr.io --username token-<customer>
For notation verify to reach the registry, Notation needs the credential too:
notation login acrmerkalisdist0c66.azurecr.io --username token-<customer>
3. Trust the signing certificate
Import the published trust anchor and trust policy (run from this directory):
notation cert add --type ca --store merkalis-dist ./merkalis-dist-ca.pem
notation policy import ./trust-policy.json
Confirm both took effect:
notation cert show --store merkalis-dist --type ca merkalis-dist-ca.pem
notation policy show
The policy is named merkalis-dist, applies to every repository on the registry, requires
strict signature verification, and skips revocation because the self-signed root has no CRL/OCSP
endpoints.
4. Pull a promoted image by semver tag
Promoted images are published under the customer namespace with semver tags:
docker pull acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-testcontainer:v0.0.1
List the tags available to you at any time with:
az acr repository show-tags --name acrmerkalisdist0c66 --repository <customer>/kastoria-testcontainer
(requires az + reader access to the dist subscription; ask Merkalis if you need the list).
5. Verify the signature
Verify the image against the trust material before use:
notation verify acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-testcontainer:v0.0.1
A successful verification prints:
Successfully verified signature for acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-testcontainer@sha256:9afc667179f1f83af9ea0a29f12397ae7cbfd3c69b8d2c63f5d964cd68aa6506
Tags are mutable, so for supply-chain-rigorous verification pin the digest instead:
notation verify acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-testcontainer@sha256:9afc667179f1f83af9ea0a29f12397ae7cbfd3c69b8d2c63f5d964cd68aa6506
6. Use signed modules (OpenTofu >= 1.10)
Module packages are published as OCI artifacts under modules/<env>/<component> and consumed
natively by OpenTofu with an oci:// source. The ?tag= selects the release (all modules share
the same version train, so the tag applies to every component):
terraform {
required_version = ">= 1.10"
}
module "compute" {
source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/compute?tag=v0.0.1"
# ... required variables for the module
}
OpenTofu reads the registry credential from ~/.docker/config.json (written by docker login in
step 2), so no extra setup is needed:
tofu init
You can also verify the module package directly with Notation, exactly like an image:
notation verify acrmerkalisdist0c66.azurecr.io/modules/azure/compute:v0.0.1
7. Air-gapped / offline environments (HITRUST)
To move a promoted image into a network-isolated environment, export it to a tarball on a
connected machine and docker load it inside the environment:
# On the connected machine
docker pull acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-testcontainer:v0.0.1
docker save acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-testcontainer:v0.0.1 \
-o kastoria-testcontainer-v0.0.1.tar
# Transfer the tarball (plus the two trust files from this directory) over your normal
# change-control / media process, then on the offline host:
docker load -i kastoria-testcontainer-v0.0.1.tar
Verify the image on the connected machine before transfer (the signature lives in the registry, so
notation verify needs registry access; the tarball itself carries no signature):
notation verify acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-testcontainer@sha256:9afc667179f1f83af9ea0a29f12397ae7cbfd3c69b8d2c63f5d964cd68aa6506
Record the verified digest alongside the tarball so the offline host can confirm the loaded image
matches what was verified (docker inspect --format '{{index .RepoDigests 0}} <image:tag>').
For module packages in an offline environment, pull the .zip layer out of the OCI artifact with
oras and unzip it into your module cache, or run tofu init on a connected host and copy the
.terraform/modules directory across.
Trust anchor fingerprints
The published certificate is self-signed (subject CN=merkalis.io, valid through 2036). Recorded
fingerprints, so the file you import can be cross-checked against an out-of-band channel:
SHA256 (S256): 6895c859070cc242f2799aa5bb456b625da3dd06e6594275d021b453f172c062
Token lifecycle
- Rotation: the token password expires ~1 year after issuance. Merkalis rotates it on the annual cycle; a new password is issued through the same channel.
- Revocation: removing a customer's entitlement immediately breaks subsequent pulls; the token is disabled at the registry.
- Audit: every pull is recorded in the registry's activity log; credentials are retrieved from the audited Key Vault only.
Docker Images
Docker Images
General Information
This section is provided in good faith as an informal guide and summary. It is not an official compliance document and does not modify, supersede, or override the official Merkalis Docker image compliance policies or security standards. Users are responsible for verifying requirements against official source documentation prior to deployment.
- Unless otherwise noted the images will run as a non-privileged user. (There are no current exceptions to this.)
- By default at least one trivy security scan has been performed on all Kastoria images as part of the build pipeline. (There are no current exceptions to this.)
- By default no image can be promoted with a known
CriticalorHighCVE finding. (There are no current exceptions to this.) - All images are signed using notation and the resulting notary v2 signature is attached to the image.
- All images will have their SLSA provenance and software attestation attached.
- All images will have their Software Bill of Materials (SBOM) attached in the SPDX format.
Image Description Layout
Each image page documents a published image with a consistent structure:
- What the image is and what it is for
- Runtime details (base image, exposed port, health check)
- OCI labels
- How to pull and verify the image
- Container-specific environment variables
- Job configuration information (for images intended to be run as a batch job)
- Available versions
Configuration
The images that read or write stored data share one configuration document, supplied through
KASTORIA_CONFIG_JSON or KASTORIA_CONFIG_FILE. See Configuration.
Images Available
| Image | Description |
|---|---|
| kastoria-proxy | Public NGINX reverse proxy: single ingress that routes traffic to every Kastoria service |
| kastoria-proxy-mtls | Mutual-TLS variant of the reverse proxy for internal traffic inside the Azure VNet |
| kastoria-health | DICOMweb server: STOW-RS ingestion, QIDO-RS queries, and WADO-RS retrieval |
| kastoria-studyprocessor | One-shot DICOM study processor that projects committed studies into the query surfaces and FHIR |
| kastoria-eventnotification | One-shot publisher that sends a Notification Event to a queue for each processed study version |
| kastoria-smart-launch-api | SMART on FHIR EHR Launch API that brokers launches from an EHR into the viewer |
| kastoria-consoleapi | Admin Console REST API for inspecting stored content and managing SMART launch configurations |
| kastoria-consoleui | Admin Console web UI: React SPA served by unprivileged NGINX |
| kastoria-ohif | OHIF DICOM viewer served by unprivileged NGINX |
| kastoria-testcontainer | Static test container used to exercise the Kastoria Health CI/CD image pipeline |
Note: The images available in the distribution registry will be prefixed with your customer name.
So, for example, kastoria-testcontainer will be available as <customer>/kastoria-testcontainer.
Configuration
The Kastoria Health images that read or write stored data — kastoria-health,
kastoria-studyprocessor, kastoria-eventnotification,
kastoria-smart-launch-api and kastoria-consoleapi — share one configuration
document. It tells a container where Kastoria Core's storage lives, how to
authenticate to it, and, for kastoria-eventnotification, which queue to
publish Notification Events to.
Supplying the configuration
The document is JSON, supplied through one of two environment variables:
| Variable | Contents |
|---|---|
KASTORIA_CONFIG_JSON |
The document itself, as an inline JSON string. |
KASTORIA_CONFIG_FILE |
A path, inside the container, to a file holding the document. |
At least one must be set, or the container fails at startup. If both are set,
KASTORIA_CONFIG_JSON takes precedence and KASTORIA_CONFIG_FILE is not read.
Kastoria Health authenticates to Azure Storage with user-assigned managed
identities, so the document names storage accounts and identity client IDs
only, and carries no credentials. When deploying with the
compute module, the document is
rendered from app_config and delivered as KASTORIA_CONFIG_JSON through an
inline container app secret on every app and job. It is not stored in Key
Vault, so its full contents are also held in OpenTofu state.
Sample configuration
An Azure deployment using a user-assigned managed identity, with the block store sharded across three storage accounts and a queue for Notification Events:
{
"organization": "Example Org",
"name": "sample",
"purpose": "production",
"uris": [
{ "type": "public", "uri": "https://sample.example.org" }
],
"storageTiers": [
{
"name": "hot",
"blockStore": [
{
"type": "azure-hybrid",
"configuration": {
"shards": [
{
"accountName": "sasampleabcdsb01",
"containerName": "hot-blocks-01",
"tableName": "hotblocks01",
"managedIdentityClientId": "00000000-0000-0000-0000-000000000001"
},
{
"accountName": "sasampleabcdsb02",
"containerName": "hot-blocks-02",
"tableName": "hotblocks02",
"managedIdentityClientId": "00000000-0000-0000-0000-000000000001"
},
{
"accountName": "sasampleabcdsb03",
"containerName": "hot-blocks-03",
"tableName": "hotblocks03",
"managedIdentityClientId": "00000000-0000-0000-0000-000000000001"
}
]
}
}
],
"namedRoot": [
{
"type": "azure-hybrid",
"configuration": {
"accountName": "sasampleabcdsn01",
"containerName": "hot-nodes-01",
"tableName": "hotnodes01",
"managedIdentityClientId": "00000000-0000-0000-0000-000000000001"
}
}
]
}
],
"queueWriter": {
"type": "azure-storage-queue",
"configuration": {
"accountName": "saqsampleabcd",
"managedIdentityClientId": "00000000-0000-0000-0000-000000000002",
"queueName": "kastoria-events",
"messageEncoding": "text"
}
}
}
Configuration parameters
Top level
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
organization |
string | Yes | — | The organization that owns the deployment. |
name |
string | Yes | — | The deployment's name. |
purpose |
string | Yes | — | What the deployment is for, e.g. production or development. |
uris |
array | No | [] |
Informational list of URIs, each with a type and a uri. Not currently read by Kastoria Health, but validated at startup: each entry needs type and uri. The compute module fills it with one public entry per container app, using the app's Container Apps URL. |
uris[].type |
string | Yes | — | The kind of URI, e.g. public. |
uris[].uri |
string | Yes | — | The URI. |
storageTiers |
array | Yes | — | The storage tiers. At least one is required. |
queueWriter |
object | No | — | The queue Notification Events are published to. Read only by kastoria-eventnotification, which fails without it; every other image ignores it. |
Any other top-level key is accepted and ignored.
When deploying with the compute
module, the document is rendered from the module's app_config input. The
module renames one field and adds two keys that Kastoria Health ignores:
| Document key | Source in the compute module |
|---|---|
organization |
app_config.organization |
name |
app_config.environment_name |
purpose |
app_config.purpose |
uris |
Generated: one public entry per container app |
storageTiers |
app_config.storage_tiers, a JSON-encoded string |
queueWriter |
app_config.queue_writer, a JSON-encoded string; omitted when null |
systemStorage |
app_config.system_storage, a JSON-encoded string. A required module input, but ignored by Kastoria Health; jsonencode([]) is sufficient |
config_version |
Added by the module as 1.0.0; ignored by Kastoria Health |
Storage tiers
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
storageTiers[].name |
string | Yes | — | The tier's name, e.g. hot. |
storageTiers[].blockStore |
array | Yes | — | Where content blocks are stored. Each entry is a backend: type plus configuration. |
storageTiers[].namedRoot |
array | Yes | — | Where Named Roots, the Changelog and Ranged Indexes are stored. Each entry is a backend: type plus configuration. |
…[].type |
string | Yes | — | Must be azure-hybrid. |
…[].configuration |
object | Yes | — | The backend's settings; see azure-hybrid configuration. |
Kastoria Health stores data in the first tier, using its first blockStore
and first namedRoot entry. Any further tiers or entries are still validated
and connected to at startup, so they must be reachable, but no data is
currently written to them — they are planned for future use.
azure-hybrid configuration
Values up to cutOff bytes are stored in an Azure Table; larger values are
stored as blobs in the container, with a pointer in the table. A backend is a
single table and container, written flat in configuration, or — on the block
store only — several, listed in shards.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
shards |
array | No | — | Block store only. Spreads blocks across several table-and-container pairs, typically in separate storage accounts, by hash. Each entry takes accountName, managedIdentityClientId, containerName, tableName and cutOff, as below; when shards is set, those fields are not read from the backend level. A one-entry array behaves as the flat form. Not accepted on the named root. |
accountName |
string | Yes | — | The storage account. |
managedIdentityClientId |
string | Yes | — | Client ID of the user-assigned managed identity to authenticate to the storage account as. The identity must be attached to the container. |
containerName |
string | Yes | — | The blob container. |
tableName |
string | Yes | — | The table. |
partitionKeyDepth |
integer | No | 1 |
Block store only: how many leading hash segments of a block's key form the table partition key. Must be at least 1, and is set on the backend, not per shard. Leave unset unless advised otherwise. |
cutOff |
integer | No | 32768 |
The largest value, in bytes, stored inline in the table (1–65536). Must be at least 1024 on the named root. |
Queue writer
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
queueWriter.type |
string | Yes | — | Must be azure-storage-queue. |
queueWriter.configuration |
object | Yes | — | The writer's settings, below. Unlike the storage backends, an unrecognized setting is refused. |
queueWriter.configuration:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
accountName |
string | Yes | — | The storage account holding the queue. |
managedIdentityClientId |
string | Yes | — | Client ID of the user-assigned managed identity to publish as. The identity must be attached to the container. With the queue module, the publisher identity's client ID. |
queueName |
string | Yes | — | The queue: 3–63 lowercase letters, digits and hyphens, starting and ending with a letter or digit, with no two hyphens in a row. |
messageEncoding |
string | Yes | — | text sends each event's JSON as-is; base64 sends it Base64-encoded. Choose the encoding your reader expects. |
createQueueIfMissing |
boolean | No | false |
Creates the queue if it does not exist. When false, a missing queue fails the publish. |
kastoria-proxy
An NGINX reverse proxy that serves as a public Kastoria ingress. The ingress
routes browser and API traffic to the DICOMweb server, SMART Launch API, Admin
Console API/UI, and OHIF viewer via port 8080.
The mTLS-enabled internal variant is a separate image, kastoria-proxy-mtls.
Runtime details
| Property | Value |
|---|---|
| Runtime base image | nginxinc/nginx-unprivileged:1.31-alpine (pinned by digest) |
| Exposed port | 8080 |
| Container user | nginx (non-root) |
| Entrypoint | /entrypoint.sh (renders the config from its template, then execs NGINX) |
| Command | CMD ["nginx", "-g", "daemon off;"] |
| Health check | GET http://localhost:8080/healthz (every 30s, 5s timeout, 30s start period, 3 retries) |
| Stop signal | SIGTERM |
Both Azure proxy variants build from a common config template. The public build strips the mutual-TLS authorization blocks at build time, so the shipped public config provably carries no role gate and the entrypoint takes no mTLS path.
OCI labels
| Label | Value |
|---|---|
org.opencontainers.image.title |
Kastoria Reverse Proxy |
org.opencontainers.image.description |
NGINX reverse proxy for the Kastoria Health platform |
org.opencontainers.image.licenses |
Proprietary |
org.opencontainers.image.url |
https://github.com/merkalis-io/kastoria-health |
org.opencontainers.image.documentation |
https://github.com/merkalis-io/kastoria-health |
io.kastoria.health-endpoint |
/ |
The org.opencontainers.image.version, org.opencontainers.image.revision,
org.opencontainers.image.created, org.opencontainers.image.source,
io.kastoria.base-image, and io.kastoria.service labels are injected at
build time by the CI/CD pipeline.
Pull and verify
Promoted images are published to the Distribution Registry under the customer namespace:
docker pull acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-proxy:<release-tag>
notation verify acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-proxy:<release-tag>
See the Distribution Registry guide for authentication and trust setup.
Environment variables
| Variable | Secret | Required | Default | Description |
|---|---|---|---|---|
KASTORIA_HEALTH_ADDRESS[1] |
No | No | kastoria-health:3000 |
Upstream DICOMweb server host and port. |
KASTORIA_SMARTLAUNCH_ADDRESS[1] |
No | No | kastoria-smartlaunch:4000 |
Upstream SMART Launch API host and port. |
KASTORIA_CONSOLEAPI_ADDRESS[1] |
No | No | kastoria-consoleapi:3005 |
Upstream Admin Console API host and port. |
KASTORIA_CONSOLEUI_ADDRESS[1] |
No | No | kastoria-consoleui:8081 |
Upstream Admin Console UI host and port. |
KASTORIA_REPORTVIEW_ADDRESS[1] |
No | No | kastoria-perfview:80 |
Upstream report viewer host and port. |
KASTORIA_REPORTVIEW_ENABLED |
No | No | true |
Set to false to strip the /reportview/ route. |
OHIF_VIEWER_ADDRESS[1] |
No | No | kastoria-ohif:8080 |
Upstream OHIF viewer host and port. |
PORT[2] |
No | No | 8080 |
Used by the container HEALTHCHECK; NGINX listens on 8080. |
Every variable above is substituted into the NGINX config at container start by
/entrypoint.sh; disabled route blocks are removed before NGINX starts.
Note: The default values listed for the various *_ADDRESS are in place for using the
proxy in a local docker compose environment. When deploying to Azure you must change these (see [1] below), or the container will fail to start.
Deploying to an Azure Container Apps Environment
[1] The various *_ADDRESS variables should be set to the environment's internal FQDN for the container app. The Azure configuration template automatically includes the correct (443) port.
[2] The selected value for PORT must match the environment's ingress Target port for the container app.
Available versions
v0.10.0v0.9.1— deprecated for security reasons (CVE-2026-93990); usev0.10.0
kastoria-proxy-mtls
An NGINX reverse proxy that serves as an internal Kastoria ingress with
support for mutual TLS (mTLS). The ingress serves hosts inside the
Azure VNet, where requests arrive over HTTP with the client certificate
provided in the X-Forwarded-Client-Cert header. Each route is gated by the
client certificate's SHA-256 thumbprint found in the client certificate's HASH=
value. The ingress listens on port 8080 and routes to the same upstreams
as the public proxy.
The public variant is a separate image, kastoria-proxy.
Runtime details
| Property | Value |
|---|---|
| Runtime base image | nginxinc/nginx-unprivileged:1.31-alpine (pinned by digest) |
| Exposed port | 8080 |
| Container user | nginx (non-root) |
| Entrypoint | /entrypoint.sh (validates the role allowlist, renders the role maps and config, then execs NGINX) |
| Command | CMD ["nginx", "-g", "daemon off;"] |
| Health check | GET http://localhost:8080/healthz (every 30s, 5s timeout, 30s start period, 3 retries) |
| Stop signal | SIGTERM |
Both Azure proxy variants build from a common config template. The mTLS build keeps the mutual-TLS authorization blocks verbatim, so the shipped config proves its mTLS posture.
Enabling mTLS Authentication
To get mTLS working, the following is required:
- TLS termination occurs at the Azure Container Apps Environment's Envoy-based ingress.
- The calling clients' CA certificate must be registered with the environment (which will allow Envoy to validate the client requests).
- The
kastoria-proxy-mtlscontainer app's ingress must be configured withclientCertificateMode: require, which ensures the client certificate exists and is forwarded to the proxy. - The
KASTORIA_MTLS_CLIENT_ROLESenvironment variable (see below) defining the mTLS routing rules must exist and be formatted correctly.
OCI labels
| Label | Value |
|---|---|
org.opencontainers.image.title |
Kastoria Reverse Proxy |
org.opencontainers.image.description |
NGINX reverse proxy for the Kastoria Health platform |
org.opencontainers.image.licenses |
Proprietary |
org.opencontainers.image.url |
https://github.com/merkalis-io/kastoria-health |
org.opencontainers.image.documentation |
https://github.com/merkalis-io/kastoria-health |
io.kastoria.health-endpoint |
/ |
The org.opencontainers.image.version, org.opencontainers.image.revision,
org.opencontainers.image.created, org.opencontainers.image.source,
io.kastoria.base-image, and io.kastoria.service labels are injected at
build time by the CI/CD pipeline.
Pull and verify
Promoted images are published to the Distribution Registry under the customer namespace:
docker pull acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-proxy-mtls:<release-tag>
notation verify acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-proxy-mtls:<release-tag>
See the Distribution Registry guide for authentication and trust setup.
Environment variables
| Variable | Secret | Required | Default | Description |
|---|---|---|---|---|
KASTORIA_MTLS_CLIENT_ROLES[1] |
Yes | Yes | — | mTLS routing rules. Startup fails if missing, empty, or malformed. |
KASTORIA_HEALTH_ADDRESS[2] |
No | No | kastoria-health:3000 |
Upstream DICOMweb server host and port. |
KASTORIA_SMARTLAUNCH_ADDRESS[2] |
No | No | kastoria-smartlaunch:4000 |
Upstream SMART Launch API host and port. |
KASTORIA_CONSOLEAPI_ADDRESS[2] |
No | No | kastoria-consoleapi:3005 |
Upstream Admin Console API host and port. |
KASTORIA_CONSOLEUI_ADDRESS[2] |
No | No | kastoria-consoleui:8081 |
Upstream Admin Console UI host and port. |
KASTORIA_REPORTVIEW_ADDRESS[2] |
No | No | kastoria-perfview:80 |
Upstream report viewer host and port. |
KASTORIA_REPORTVIEW_ENABLED |
No | No | true |
Set to false to strip the /reportview/ route. |
OHIF_VIEWER_ADDRESS[2] |
No | No | kastoria-ohif:8080 |
Upstream OHIF viewer host and port. |
PORT[3] |
No | No | 8080 |
Used by the container HEALTHCHECK; NGINX listens on 8080. |
Every variable above is substituted into the NGINX config at container start by
/entrypoint.sh, which also validates the mTLS routing rules and renders the role
maps; disabled route blocks are removed before NGINX starts.
Note: The default values listed for the various *_ADDRESS are in place for using the
non-mTLS proxy in a local docker compose environment. When deploying to Azure you must change these (see [2] below), or the container will fail to start.
mTLS Routing Rules
The mTLS routing rule is provided in the KASTORIA_MTLS_CLIENT_ROLES environment variable. It is a routing allowlist that maps each client certificate to its roles in the following format:
Thumbprint→role allowlist, <sha256hex>:role1,role2;<sha256hex>:role3;....
Thumbprint is the SHA-256 hash of the client's certificate as a hex string.
role is defined in the following way:
| Role | Grants |
|---|---|
dicomweb |
/studies, /errors |
smart |
/smart/ |
console |
/api/, /console/ |
reportview |
/reportview/ |
ohif |
/ (catch-all) |
all |
every route |
Note: The proxy's /healthz endpoint is exempt from the mTLS checks.
Deploying to an Azure Container Apps Environment
[1] The role allowlist should be supplied via a secretref: to a Key Vault secret. To allow automatic updates, this should be a versionless secret. The value of KASTORIA_MTLS_CLIENT_ROLES is validated at startup. a) Thumbprints must be exactly 64 hex characters, b) No whitespace or newlines are allowed, and c) There must be no empty or duplicate entries.
[2] The various *_ADDRESS variables should be set to the environment's internal FQDN for the container app. The Azure configuration template automatically includes the correct (443) port.
[3] The selected value for PORT must match the environment's ingress Target port for the container app.
Available versions
v0.10.0v0.9.1— deprecated for security reasons (CVE-2026-93990); usev0.10.0
kastoria-health
A DICOMweb server that serves STOW-RS ingestion, QIDO-RS queries, and WADO-RS
retrieval over the studies stored in kastoria-storage. The server backs the
kastoria-proxy reverse proxy's /studies path and is the
viewer's source of pixel data.
Runtime details
| Property | Value |
|---|---|
| Runtime base image | kastoria-core (final stage), itself node:24-alpine |
| Exposed port | 3000 |
| Container user | node (non-root) |
| Entrypoint | node --import /app/dicomweb/src/instrumentation.js /app/dicomweb/src/server.js |
| Health check | GET http://localhost:3000/health (every 30s, 5s timeout, 30s start period, 3 retries) |
| Stop signal | SIGTERM |
node_modules for npm/npx are stripped from the runtime image.
OCI labels
| Label | Value |
|---|---|
org.opencontainers.image.title |
Kastoria Health DICOMweb Server |
org.opencontainers.image.description |
DICOMweb server for the Kastoria Health platform |
org.opencontainers.image.licenses |
Proprietary |
org.opencontainers.image.url |
https://github.com/merkalis-io/kastoria-health |
org.opencontainers.image.documentation |
https://github.com/merkalis-io/kastoria-health |
io.kastoria.health-endpoint |
/health |
The org.opencontainers.image.version, org.opencontainers.image.revision,
org.opencontainers.image.created, org.opencontainers.image.source,
io.kastoria.base-image, and io.kastoria.service labels are injected at
build time by the CI/CD pipeline.
Pull and verify
Promoted images are published to the Distribution Registry under the customer namespace:
docker pull acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-health:<release-tag>
notation verify acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-health:<release-tag>
See the Distribution Registry guide for authentication and trust setup.
Environment variables
| Variable | Secret | Required | Default | Description |
|---|---|---|---|---|
NODE_ENV |
No | Yes | production |
Environment; startup fails if unset. |
HOST |
No | Yes | 0.0.0.0 |
not used. |
PORT[1] |
No | No | 3000 |
HTTP port. |
DICOMWEB_URL |
No | No | — | Public base URL of this service. Used for STOW-RS RetrieveURL values; every non-loopback deployment should set it. |
KASTORIA_CONFIG_JSON[2] |
Yes | one of these | — | Storage configuration as an inline JSON string. |
KASTORIA_CONFIG_FILE[2] |
No | one of these | — | Path to a storage configuration JSON file. |
SHUTDOWN_HARD_TIMEOUT_MS |
No | No | 30000 |
Hard deadline in ms for graceful shutdown. |
QIDO_MAX_INDEX_ENTRIES |
No | No | 25000 |
Index entries a multi-attribute QIDO search may read before it reports how far back it reached. |
QIDO_CANDIDATE_CONCURRENCY |
No | No | 20 |
How many candidate studies a QIDO search loads at once. |
WADO_MAX_FRAMES |
No | No | unset | Most frames one /rendered or /thumbnail request may name; a request naming more is refused with 400. Unset or 0 is uncapped. |
JWT_VERIFYING_KEY[3,4] |
Yes | No | feature off | RS256 PEM public key used to verify JWT auth tokens. Setting it turns on per-study viewer-token verification. |
JWT_ISSUER[4] |
No | No | kastoria-smart-launch |
Expected iss claim when verification is on. |
JWT_AUDIENCE[4] |
No | No | kastoria-health |
Expected aud claim when verification is on. |
LRU_NUMBER_OF_ITEMS |
No | No | 1000 |
Maximum entries per in-process LRU cache (four caches each apply this limit). |
LRU_MAX_SIZE_BYTES |
No | No | 2000000000 |
Maximum total bytes per LRU cache. |
LRU_TTL_MS |
No | No | 432000000 |
LRU entry time-to-live in ms. |
STUDY_TTL_MS |
No | No | 10000 |
TTL in ms for the DICOMProcessed study-root CID cache. |
OTEL_ENABLED[5] |
No | No | false |
Set to true to enable OpenTelemetry export. |
OTEL_EXPORTER_OTLP_ENDPOINT[5] |
No | No | — | OTLP collector endpoint, e.g. http://otel-gateway:4317. |
OTEL_DEBUG[5] |
No | No | — | Set to true for verbose OTel diagnostic logging. |
Deploying to an Azure Container Apps Environment
[1] The selected value for PORT must match the environment's ingress Target port for the container app.
[2] At least one of KASTORIA_CONFIG_JSON / KASTORIA_CONFIG_FILE is required for storage initialization. If both are set the value of KASTORIA_CONFIG_JSON is used. See Configuration for the document's contents.
[3] If JWT_VERIFYING_KEY is not set (or set to an empty string), JWT verification (JWT Authorization) is disabled. Note: JWT verification
only covers the QIDO-RS and WADO-RS read routes.
[4] If enabled, incoming JWT tokens presented via the Authorization: Bearer <jwt-token>
header are verified using JWT_VERIFYING_KEY. A valid token will have an
iss that matches JWT_ISSUER, and aud that matches JWT_AUDIENCE.
Both exp and nbf are enforced if provided.
[5] If OTEL_ENABLED is not explicitly set to true, OpenTelemetry metric/trace/logging is disabled.
Available versions
v0.10.0v0.9.1— deprecated for security reasons (CVE-2026-101916); usev0.10.0
kastoria-studyprocessor
A DICOM study processor that follows the DICOMStudy Changelog and projects
each committed study into the query surfaces (DICOMProcessed) and FHIR. It is
a one-shot image with no listening port: one invocation is one Execution,
which becomes a Run by taking the Run Lease, then exits. A deployment starts it
on a schedule.
Runtime details
| Property | Value |
|---|---|
| Runtime base image | kastoria-core (final stage), itself node:24-alpine |
| Exposed port | none (CLI, runs to completion) |
| Container user | node (non-root) |
| Entrypoint | node --import ./dicom-study-processor/src/instrumentation.js ./dicom-study-processor/bin/cli.js |
| Health check | none (not a long-running service) |
| Stop signal | SIGTERM (a signaled Run records its outcome and exits 0) |
Exit codes:
0 when the Lease was taken and released whatever the Run did, and
also 0 when another Execution holds the Lease (the no-op tick).
Non-zero exits occur when:
- A failure happened before the Lease was reached — configuration or storage initialization.
- A bad input argument is provided.
- A second
SIGTERMis received. - Reading the Lease failed.
- An exception is thrown during the close.
node_modules for npm/npx are stripped from the runtime image.
OCI labels
| Label | Value |
|---|---|
org.opencontainers.image.title |
DICOM Study Processor |
org.opencontainers.image.description |
Study processing pipeline for Kastoria Health |
org.opencontainers.image.licenses |
Proprietary |
org.opencontainers.image.url |
https://github.com/merkalis-io/kastoria-health |
org.opencontainers.image.documentation |
https://github.com/merkalis-io/kastoria-health |
io.kastoria.health-endpoint |
(empty — no health endpoint) |
The org.opencontainers.image.version, org.opencontainers.image.revision,
org.opencontainers.image.created, org.opencontainers.image.source,
io.kastoria.base-image, and io.kastoria.service labels are injected at
build time by the CI/CD pipeline.
Pull and verify
Promoted images are published to the Distribution Registry under the customer namespace:
docker pull acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-studyprocessor:<release-tag>
notation verify acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-studyprocessor:<release-tag>
See the Distribution Registry guide for authentication and trust setup.
Environment variables
| Variable | Secret | Required | Default | Description |
|---|---|---|---|---|
NODE_ENV |
No | No | production |
Environment; startup fails if unset. |
KASTORIA_CONFIG_JSON[1] |
Yes | one of these | — | Storage configuration as an inline JSON string. |
KASTORIA_CONFIG_FILE[1] |
No | one of these | — | Path to a storage configuration JSON file. |
STUDY_PROCESSOR_INSTANCE_CONCURRENCY |
No | No | 20 |
How many SOP instances of one study are in flight at once. |
STUDY_PROCESSOR_STUDY_CONCURRENCY |
No | No | 4 |
How many studies a Run projects at once; multiplies against the instance concurrency. |
STUDY_PROCESSOR_VISIBILITY_DELAY_MS |
No | No | 5000 |
How far short of NOW a Changelog read stops. |
STUDY_PROCESSOR_BATCH_SIZE |
No | No | 100 |
Entries one bounded Changelog read asks for. |
STUDY_PROCESSOR_RUN_BUDGET_MS |
No | No | 600000 |
Run Budget: how long, from process start, a Run may take new work. Floor 60000; a lower value throws at startup. |
SHUTDOWN_HARD_TIMEOUT_MS |
No | No | 10000 |
Hard deadline in ms for the tear-down after a Run ends. |
OTEL_ENABLED[2] |
No | No | false |
Set to true to enable OpenTelemetry export. |
OTEL_EXPORTER_OTLP_ENDPOINT[2] |
No | No | — | OTLP collector endpoint, e.g. http://otel-gateway:4317. |
OTEL_DEBUG[2] |
No | No | — | Set to true for verbose OTel diagnostic logging. |
The CLI subcommand (process DICOMStudy) is passed at run time.
Deploying to an Azure Container Apps Environment
[1] At least one of KASTORIA_CONFIG_JSON / KASTORIA_CONFIG_FILE is required for storage initialization. If both are set the value of KASTORIA_CONFIG_JSON is used. See Configuration for the document's contents.
[2] If OTEL_ENABLED is not explicitly set to true, OpenTelemetry metric/trace/logging is disabled.
Job configuration
The image is meant to run as a batch job that is setup to be run on a regular schedele. There is no long-running process to keep alive; the deployment is responsible for starting execution on a cadence. The following launch arguments should be passed into the container
["process", "DICOMStudy"]
To details on setting up the execution cadence and launch arguments in a deployed environment, see the
jobs configuration documentation for the appropriate Terrform/OpenTofu compute module.
Available versions
v0.10.0v0.9.1— deprecated for security reasons (CVE-2026-101916); usev0.10.0
kastoria-eventnotification
An event notification publisher that follows the DICOMProcessed Changelog and
publishes one Notification Event per study version to a queue. It is a
one-shot image with no listening port: one invocation is one Execution,
which becomes a Run by taking the Run Lease, publishes until it has caught up
with the Changelog, and then exits. A deployment starts it on a schedule.
The events it publishes are described in Ingestion Manifest.
Runtime details
| Property | Value |
|---|---|
| Runtime base image | kastoria-core (final stage), itself node:24-alpine |
| Exposed port | none (CLI, runs to completion) |
| Container user | node (non-root) |
| Entrypoint | node --import ./event-notification/src/instrumentation.js ./event-notification/bin/cli.js |
| Health check | none (not a long-running service) |
| Stop signal | SIGTERM (the Run takes no new work, finishes what is in flight and exits normally) |
Exit codes:
0 when the Lease was taken and the Run ended without a failed publish,
including a Run stopped by SIGTERM. Also 0 when another Execution holds the
Lease (the no-op tick).
Non-zero exits occur when:
- A Run stopped on an event it could not publish. That event is held, and the next Run publishes it again.
- A failure happened before the Lease was reached — configuration, storage
initialization, or building the queue writer (for example, a missing or
invalid
queueWritersection). - A bad input argument is provided.
- A second
SIGTERMis received. - Taking the Lease or reading the Changelog failed.
A failure while closing storage or flushing telemetry after the Run does not change the exit code.
node_modules for npm/npx are stripped from the runtime image.
OCI labels
| Label | Value |
|---|---|
org.opencontainers.image.title |
Event Notification |
org.opencontainers.image.description |
Publishes Notification Events for Kastoria Health |
org.opencontainers.image.licenses |
Proprietary |
org.opencontainers.image.url |
https://github.com/merkalis-io/kastoria-health |
org.opencontainers.image.documentation |
https://github.com/merkalis-io/kastoria-health |
io.kastoria.health-endpoint |
(empty — no health endpoint) |
The org.opencontainers.image.version, org.opencontainers.image.revision,
org.opencontainers.image.created, org.opencontainers.image.source,
io.kastoria.base-image, and io.kastoria.service labels are injected at
build time by the CI/CD pipeline.
Pull and verify
Promoted images are published to the Distribution Registry under the customer namespace:
docker pull acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-eventnotification:<release-tag>
notation verify acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-eventnotification:<release-tag>
See the Distribution Registry guide for authentication and trust setup.
Environment variables
| Variable | Secret | Required | Default | Description |
|---|---|---|---|---|
NODE_ENV |
No | No | production |
Environment; startup fails if unset. |
KASTORIA_CONFIG_JSON[1][2] |
Yes | one of these | — | Storage configuration as an inline JSON string. |
KASTORIA_CONFIG_FILE[1][2] |
No | one of these | — | Path to a storage configuration JSON file. |
EVENT_NOTIFICATION_PUBLISH_CONCURRENCY |
No | No | 16 |
How many Notification Events a Run has in flight at once. |
EVENT_NOTIFICATION_VISIBILITY_DELAY_MS |
No | No | 5000 |
How far short of NOW a Changelog read stops. |
EVENT_NOTIFICATION_BATCH_SIZE |
No | No | 100 |
Entries one bounded Changelog read asks for. |
AZURE_CLIENT_ID[3] |
No | No | — | Client id of the user-assigned managed identity used to reach Azure storage. |
OTEL_ENABLED[4] |
No | No | false |
Set to true to enable OpenTelemetry export. |
OTEL_EXPORTER_OTLP_ENDPOINT[4] |
No | No | — | OTLP collector endpoint, e.g. http://otel-gateway:4317. |
OTEL_DEBUG[4] |
No | No | — | Set to true for verbose OTel diagnostic logging. |
The EVENT_NOTIFICATION_* values must be positive integers; any other value
fails at startup.
The CLI subcommand (publish) is passed at run time.
Deploying to an Azure Container Apps Environment
[1] At least one of KASTORIA_CONFIG_JSON / KASTORIA_CONFIG_FILE is required for storage initialization. If both are set the value of KASTORIA_CONFIG_JSON is used. See Configuration for the document's contents.
[2] The configuration must include a queueWriter section naming the queue to publish to. With the
Azure Storage Queue module the writer authenticates with a
managed identity, so the section carries no credential:
"queueWriter": {
"type": "azure-storage-queue",
"configuration": {
"accountName": "<queue storage account name>",
"managedIdentityClientId": "<queue publisher identity client id>",
"queueName": "kastoria-events",
"messageEncoding": "text"
}
}
messageEncoding is text (the JSON as-is) or base64 (the JSON's UTF-8 bytes, Base64-encoded). The
queue is not created unless createQueueIfMissing is true; a missing queue otherwise fails the Run.
The identity needs the Storage Queue Data Message Sender role on the queue's storage account; the queue
module creates one, umi-<base_name>-queue-publisher, and grants it that role.
[3] AZURE_CLIENT_ID selects the storage identity, not the queue publisher. The container reads the
Changelog from storage and publishes to the queue, so it carries two user-assigned identities: the storage
identity (named by AZURE_CLIENT_ID) and the queue publisher (named by managedIdentityClientId in the
queueWriter section). With the compute module, attach them through user_managed_storage_app and
user_managed_queue_app.
[4] If OTEL_ENABLED is not explicitly set to true, OpenTelemetry metric/trace/logging is disabled.
Job configuration
The image is meant to run as a batch job that is setup to be run on a regular schedele. There is no long-running process to keep alive; the deployment is responsible for starting execution on a cadence. The following launch arguments should be passed into the container
["publish"]
To details on setting up the execution cadence and launch arguments in a deployed environment, see the
jobs configuration documentation for the appropriate Terrform/OpenTofu compute module.
Available versions
v0.10.0v0.9.1— deprecated for security reasons (CVE-2026-101916); usev0.10.0
kastoria-smart-launch-api
A SMART on FHIR EHR Launch API service that brokers launches from an EHR into
the viewer. The API acts as a SMART client, exposing the /launch, /callback,
and /error endpoints under /smart/, and optionally mints the viewer JWT that
scopes per-study access. It sits behind a kastoria-proxy or
kastoria-proxy-mtls reverse proxy.
Runtime details
| Property | Value |
|---|---|
| Runtime base image | kastoria-core (final stage), itself node:24-alpine |
| Exposed port | 4000 |
| Container user | node (non-root) |
| Entrypoint | node --import /app/smart-launch-api/src/instrumentation.js /app/smart-launch-api/src/server.js |
| Health check | GET http://localhost:4000/health (every 30s, 5s timeout, 30s start period, 3 retries) |
| Stop signal | SIGTERM |
node_modules for npm/npx are stripped from the runtime image.
OCI labels
| Label | Value |
|---|---|
org.opencontainers.image.title |
Smart Launch API |
org.opencontainers.image.description |
SMART on FHIR launch API for Kastoria Health |
org.opencontainers.image.licenses |
Proprietary |
org.opencontainers.image.url |
https://github.com/merkalis-io/kastoria-health |
org.opencontainers.image.documentation |
https://github.com/merkalis-io/kastoria-health |
io.kastoria.health-endpoint |
/health |
The org.opencontainers.image.version, org.opencontainers.image.revision,
org.opencontainers.image.created, org.opencontainers.image.source,
io.kastoria.base-image, and io.kastoria.service labels are injected at
build time by the CI/CD pipeline.
Pull and verify
Promoted images are published to the Distribution Registry under the customer namespace:
docker pull acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-smart-launch-api:<release-tag>
notation verify acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-smart-launch-api:<release-tag>
See the Distribution Registry guide for authentication and trust setup.
Environment variables
| Variable | Secret | Required | Default | Description |
|---|---|---|---|---|
NODE_ENV |
No | No | production |
Environment; startup fails if unset. |
HOST |
No | No | 0.0.0.0 |
Bind address; startup fails if unset. |
OHIF_VIEWER_URL |
No | Yes | — | OHIF viewer base URL for the SMART Launch redirect; startup fails if unset. |
KASTORIA_CONFIG_JSON[1] |
Yes | one of these | — | Storage configuration as an inline JSON string. |
KASTORIA_CONFIG_FILE[1] |
No | one of these | — | Path to a storage configuration JSON file. |
PORT[2] |
No | No | 4000 |
HTTP port. |
HTTP_PROTOCOL |
No | No | https |
Scheme used when deriving the redirect base URL from the request. Ignored when REDIRECT_HOST is set. |
REDIRECT_HOST |
No | No | — | Base URL (scheme and host) the service advertises in redirects. Set it behind a proxy or inside a container. |
JWT_SIGNING_KEY[3,4] |
Yes | No | feature off | RSA private key for signing the viewer JWT. Feature is off when unset. Accepts real newlines or escaped \n. |
JWT_TTL_SECONDS[4] |
No | No | 3600 |
Viewer JWT expiry in seconds; must be a positive integer, or startup fails. |
JWT_ISSUER[4] |
No | No | kastoria-smart-launch |
iss claim of the viewer JWT. |
JWT_AUDIENCE[4] |
No | No | kastoria-health |
aud claim of the viewer JWT. |
SHUTDOWN_HARD_TIMEOUT_MS |
No | No | 10000 |
Hard deadline in ms for graceful shutdown. |
OTEL_ENABLED[5] |
No | No | false |
Set to true to enable OpenTelemetry export. |
OTEL_EXPORTER_OTLP_ENDPOINT[5] |
No | No | — | OTLP collector endpoint, e.g. http://otel-gateway:4317. |
OTEL_DEBUG[5] |
No | No | — | Set to true for verbose OTel diagnostic logging. |
Per-EHR clientId and state secret are stored in the Admin Console and read from
shared storage, so changing one needs no restart.
Deploying to an Azure Container Apps Environment
[1] At least one of KASTORIA_CONFIG_JSON / KASTORIA_CONFIG_FILE is required for storage initialization. If both are set the value of KASTORIA_CONFIG_JSON is used. See Configuration for the document's contents.
[2] The selected value for PORT must match the environment's ingress Target port for the container app.
[3] If JWT_SIGNING_KEY is not set (or set to an empty string), JWT token minting (JWT Authorization) is disabled.
[4] The viewer JWT minted here is verified by kastoria-health; see JWT Authorization.
The values of JWT_ISSUER and JWT_AUDIENCE are copied verbatim into the JWT token's iss and aud fields, respectively, and the exp field is
set to now + JWT_TTL_SECONDS.
[5] If OTEL_ENABLED is not explicitly set to true, OpenTelemetry metric/trace/logging is disabled.
Available versions
v0.10.0v0.9.1— deprecated for security reasons (CVE-2026-101916); usev0.10.0
kastoria-consoleapi
An Admin Console REST API that lets operators inspect stored content and manage
SMART launch configurations. The API serves all routes under /api and sits
behind a kastoria-proxy or
kastoria-proxy-mtls reverse proxy.
Runtime details
| Property | Value |
|---|---|
| Runtime base image | kastoria-core (final stage), itself node:24-alpine |
| Exposed port | 3005 |
| Container user | node (non-root) |
| Entrypoint | node --import /app/admin-console-web-api/src/instrumentation.js /app/admin-console-web-api/src/server.js |
| Health check | GET http://localhost:3005/health (every 30s, 5s timeout, 30s start period, 3 retries) |
| Stop signal | SIGTERM |
OCI labels
| Label | Value |
|---|---|
org.opencontainers.image.title |
Kastoria Admin Console API |
org.opencontainers.image.description |
Admin console REST API for the Kastoria Health platform |
org.opencontainers.image.licenses |
Proprietary |
org.opencontainers.image.url |
https://github.com/merkalis-io/kastoria-health |
org.opencontainers.image.documentation |
https://github.com/merkalis-io/kastoria-health |
io.kastoria.health-endpoint |
/health |
The org.opencontainers.image.version, org.opencontainers.image.revision,
org.opencontainers.image.created, org.opencontainers.image.source,
io.kastoria.base-image, and io.kastoria.service labels are injected at
build time by the CI/CD pipeline.
Pull and verify
Promoted images are published to the Distribution Registry under the customer namespace:
docker pull acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-consoleapi:<release-tag>
notation verify acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-consoleapi:<release-tag>
See the Distribution Registry guide for authentication and trust setup.
Environment variables
| Variable | Secret | Required | Default | Description |
|---|---|---|---|---|
NODE_ENV |
No | No | production |
Environment; startup fails if unset. |
HOST |
No | No | 0.0.0.0 |
Bind address; startup fails if unset. |
OHIF_VIEWER_URL |
No | Yes | — | OHIF viewer base URL used for launching studies; startup fails if unset. |
KASTORIA_CONFIG_JSON[1] |
Yes | one of these | — | Storage configuration as an inline JSON string. |
KASTORIA_CONFIG_FILE[1] |
No | one of these | — | Path to a storage configuration JSON file. |
PORT[2] |
No | No | 3005 |
HTTP port. |
SHUTDOWN_HARD_TIMEOUT_MS |
No | No | 10000 |
Hard deadline in ms for graceful shutdown. |
DEMO_FEATURES_ENABLED |
No | No | false |
Set to true to show demo / under-construction UI features. Off for customer environments. |
OTEL_ENABLED[3] |
No | No | false |
Set to true to enable OpenTelemetry export. |
OTEL_EXPORTER_OTLP_ENDPOINT[3] |
No | No | — | OTLP collector endpoint, e.g. http://otel-gateway:4317. |
OTEL_DEBUG[3] |
No | No | — | Set to true for verbose OTel diagnostic logging. |
Deploying to an Azure Container Apps Environment
[1] At least one of KASTORIA_CONFIG_JSON / KASTORIA_CONFIG_FILE is required for storage initialization. If both are set the value of KASTORIA_CONFIG_JSON is used. See Configuration for the document's contents.
[2] The selected value for PORT must match the environment's ingress Target port for the container app.
[3] If OTEL_ENABLED is not explicitly set to true, OpenTelemetry metric/trace/logging is disabled.
Available versions
v0.10.0v0.9.1— deprecated for security reasons (CVE-2026-101916); usev0.10.0
kastoria-consoleui
An Admin Console web UI served by an unprivileged NGINX as a compiled React
single-page application. The UI is designed to sit behind a
kastoria-proxy or kastoria-proxy-mtls
reverse proxy under the /console/. This application makes calls into the
kastoria-consoleapi and
kastoria-health container apps.
Runtime details
| Property | Value |
|---|---|
| Build stage | node:24-alpine (npm run build of admin-console-web-ui) |
| Runtime base image | nginxinc/nginx-unprivileged:1.31-alpine (pinned by digest) |
| Exposed port | 8081 |
| Container user | nginx (non-root) |
| Command | CMD ["nginx", "-g", "daemon off;"] |
| Health check | GET http://localhost:8081/ (every 30s, 5s timeout, 30s start period, 3 retries) |
| Stop signal | SIGTERM |
The UI is a static SPA; the API base URL and asset base path are compiled into the bundle at build time, not configured at runtime.
OCI labels
| Label | Value |
|---|---|
org.opencontainers.image.title |
Kastoria Admin Console UI |
org.opencontainers.image.description |
Admin console web UI for the Kastoria Health platform |
org.opencontainers.image.licenses |
Proprietary |
org.opencontainers.image.url |
https://github.com/merkalis-io/kastoria-health |
org.opencontainers.image.documentation |
https://github.com/merkalis-io/kastoria-health |
io.kastoria.health-endpoint |
/ |
The org.opencontainers.image.version, org.opencontainers.image.revision,
org.opencontainers.image.created, org.opencontainers.image.source,
io.kastoria.base-image, and io.kastoria.service labels are injected at
build time by the CI/CD pipeline.
Pull and verify
Promoted images are published to the Distribution Registry under the customer namespace:
docker pull acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-consoleui:<release-tag>
notation verify acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-consoleui:<release-tag>
See the Distribution Registry guide for authentication and trust setup.
Environment variables
| Variable | Secret | Required | Default | Description |
|---|---|---|---|---|
PORT |
No | No | 8081 |
Only used by the container HEALTHCHECK; NGINX listens on 8081. |
Deploying to an Azure Container Apps Environment
none
Available versions
v0.10.0v0.9.1— deprecated for security reasons (CVE-2026-93990); usev0.10.0
kastoria-ohif
The OHIF web-based DICOM viewer, built locally from the
upstream OHIF/Viewers submodule and served by an unprivileged NGINX. The
viewer is designed to sit behind a kastoria-proxy and/or
an kastoria-proxy-mtls reverse proxy as the catch-all
/ route, retrieving pixel data from kastoria-health via
DICOMweb (QIDO-RS/WADO-RS). This viewer also provides support for automatically
handling JWT authorization for redirects from the
kastoria-smart-launch-api container app,
picking up its study context and (optional) viewer JWT from the
URL fragment.
Runtime details
| Property | Value |
|---|---|
| Build stage | node:24-alpine (pnpm run build of the OHIF/Viewers submodule, pinned to v3.13) |
| Runtime base image | nginxinc/nginx-unprivileged:1.31-alpine (pinned by digest) |
| Exposed port | 8080 |
| Container user | nginx (non-root) |
| Entrypoint | /usr/src/entrypoint.sh (injects branding CSS, renders app-config.js from its template, then execs NGINX) |
| Command | CMD ["nginx", "-g", "daemon off;"] |
| Health check | GET http://localhost:8080/ (every 30s, 5s timeout, 30s start period, 3 retries) |
| Stop signal | SIGTERM |
The viewer is a static SPA. Branding (logo, name, version, header URL) and study-list visibility are baked into the bundle at image build time; only the DICOMweb endpoint is resolved by the entrypoint at container startup (see Environment variables below).
OCI labels
| Label | Value |
|---|---|
org.opencontainers.image.title |
OHIF Viewer |
org.opencontainers.image.description |
OHIF medical image viewer |
org.opencontainers.image.licenses |
MIT |
org.opencontainers.image.url |
https://github.com/OHIF/Viewers |
org.opencontainers.image.documentation |
https://docs.ohif.org/ |
io.kastoria.health-endpoint |
/ |
Unlike the other Kastoria-authored images, this image packages the upstream
OHIF Viewers project, so its labels point at the OHIF project itself and its
license is MIT rather than Proprietary.
The org.opencontainers.image.version, org.opencontainers.image.revision,
org.opencontainers.image.created, org.opencontainers.image.source,
io.kastoria.base-image, and io.kastoria.service labels are injected at
build time by the CI/CD pipeline.
Pull and verify
Promoted images are published to the Distribution Registry under the customer namespace:
docker pull acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-ohif:<release-tag>
notation verify acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-ohif:<release-tag>
See the Distribution Registry guide for authentication and trust setup.
Environment variables
| Variable | Secret | Required | Default | Description |
|---|---|---|---|---|
DICOMWEB_URL[1] |
No | Yes | — | Base URL of the DICOMweb server the viewer queries, e.g. https://dicomweb.example.com. Set to same-origin to use the viewer's own origin. Startup fails if unset. |
PUBLIC_URL |
No | No | / |
Public URL path prefix the viewer is served under. |
PORT[2] |
No | No | 8080 |
HTTP port; also used by the container HEALTHCHECK. |
APP_CONFIG |
No | No | — | Inline app-config.js content that replaces the generated config entirely. |
The entrypoint substitutes DICOMWEB_URL into the built-in app-config.js at
container start; it does not rebuild the image.
Deploying to an Azure Container Apps Environment
[1] DICOMWEB_URL is resolved by the user's browser, not by the container, so it must be a publicly reachable URL. When the viewer is served behind the reverse proxy on the same host, set it to same-origin (or the proxy's public URL) so DICOMweb requests are routed through the proxy to kastoria-health.
[2] The selected value for PORT must match the environment's ingress Target port for the container app.
Build arguments
Branding and a handful of viewer defaults are fixed when the image is built and are not configurable at container runtime:
| Argument | Default | Description |
|---|---|---|
BRAND_NAME |
Merkalis |
Short brand name shown in the header next to the logo. |
BRAND_VIEWER_NAME |
Merkalis Viewer |
Full viewer name shown in the About box and browser tab. |
BRAND_VIEWER_VERSION |
3.13.0 |
Viewer version shown in the About box and browser tab. |
BRAND_VIEWER_URL |
https://merkalis.io |
URL shown in the About box. |
BRAND_ASSETS |
merkalis |
Branding subdirectory to copy logo.png/branding.css from. |
SHOW_STUDY_LIST |
true |
Set to false to hide the study list on startup. |
LOCATION |
deploy |
NGINX server block: deploy serves static files only; local also proxies /studies, /wado. |
The Kastoria distribution builds with LOCATION=deploy, since
kastoria-proxy (or kastoria-proxy-mtls) handles DICOMweb routing.
Available versions
v0.10.0v0.9.1— deprecated for security reasons (CVE-2026-93990); usev0.10.0
kastoria-testcontainer
A minimal static container used to exercise the Kastoria Health CI/CD image pipeline (build → sign → verify). It serves a static page so the deployed and verified image is also observable.
Runtime details
| Property | Value |
|---|---|
| Runtime base image | nginxinc/nginx-unprivileged:1.31-alpine (pinned by digest) |
| Exposed port | 8083 |
| Container user | nginx (non-root) |
| Health check | GET http://localhost:8083/ (every 30s, 30s start period, 3 retries) |
| Stop signal | SIGTERM |
OCI labels
| Label | Value |
|---|---|
org.opencontainers.image.title |
Kastoria Test Container |
org.opencontainers.image.description |
Static test container used to exercise the Kastoria Health CI/CD image pipeline |
org.opencontainers.image.licenses |
Proprietary |
io.kastoria.health-endpoint |
/ |
All OCI labels are injected at build time by the CI/CD pipeline.
Pull and verify
Promoted images are published to the Distribution Registry under the customer namespace:
docker pull acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-testcontainer:v0.0.1
notation verify acrmerkalisdist0c66.azurecr.io/<customer>/kastoria-testcontainer:v0.0.1
See the Distribution Registry guide for authentication and trust setup.
Environment variables
There are no container-specific environment variables for kastoria-testcontainer.
Available versions
v0.0.1
Terraform/Tofu Modules
Terraform/Tofu Modules
Merkalis publishes OpenTofu modules that compose into a complete Kastoria environment on Azure. The sample deployment below illustrates what they provision when applied together; the module pages that follow document each one.
Sample Deployment
The diagram below shows the resources created when the sample environment configuration is applied. It is a reference deployment that the Merkalis Terraform/OpenTofu modules listed below are designed to produce.
graph TB
subgraph AZURE["Azure"]
subgraph INFRA["Azure subscription (infra)"]
ACR["ACR<br/>infra-rg"]
end
subgraph DEV["Azure subscription (dev) · RG rg"]
UMI["User Managed Identity<br/>umi-*-secrets<br/>umi-*-app-id"]
subgraph NET["Networking"]
subgraph VNET["VNet vnet<br/>10.x.0.0/16"]
SUB_COMPUTE["compute · 10.x.0.0/21<br/>Service Endpoints: Microsoft.ContainerRegistry, Microsoft.KeyVault, Microsoft.Storage<br/>delegated to Microsoft.App"]
SUB_STORAGE["storage · 10.x.8.0/24<br/>Service Endpoints: Microsoft.Storage"]
end
end
subgraph SEC["Secrets"]
KV["Key Vault kv-*-1a99<br/>RBAC · deny-by-default"]
UMI_SEC["umi-*-secrets"]
KV -.->|Key Vault Secrets User| UMI_SEC
end
subgraph OBJ["fa:fa-id-badge Object store - sharded"]
subgraph SN["node storage account(s)<br/>1..n"]
SN_BLOB["BLOB store"]
SN_TAB["table store"]
end
subgraph SB["block storage account(s)<br/>1..n"]
SB_BLOB["BLOB store"]
SB_TAB["table store"]
end
end
subgraph COMPUTE["Container App Environment"]
LA["Log Analytics logs"]
OHIF["app-*--ohif<br/>OHIF viewer · :8080 ext"]
KAST["fa:fa-id-badge app-*--kastoria<br/>:3000 ext"]
SMART["fa:fa-id-badge app-*--smartlaunchapi<br/>:4000 ext"]
PROXY["app-*--kastoria-proxy<br/>:8080 int"]
OTEL["otel-gateway<br/>OTel collector · :4317 int"]
JPROC["fa:fa-id-badge job-*-stdyproc<br/>study processor"]
end
end
end
NET ~~~ SEC
UMI ~~~ NET
PROXY <--> KAST
PROXY <--> SMART
PROXY <--> OHIF
ACR -.->|AcrPull| PROXY
ACR -.->|AcrPull| OTEL
KAST <-.-> OBJ
SMART <-.-> OBJ
JPROC <-.-> OBJ
KAST -.->|metrics/traces| OTEL
SMART -.->|metrics/traces| OTEL
OHIF -.->|metrics/traces| OTEL
JPROC -.->|metrics/traces| OTEL
USER --> PROXY
OHIF --> LA
KAST --> LA
SMART --> LA
PROXY --> LA
JPROC --> LA
classDef computeFill fill:#dbeafe,stroke:#1e40af,color:#000;
classDef storageFill fill:#dcfce7,stroke:#166534,color:#000;
classDef gatewayFill fill:#fef3c7,stroke:#92400e,color:#000;
class SUB_COMPUTE computeFill
class SUB_STORAGE storageFill
class SUB_GATEWAY gatewayFill
class LA,OHIF,KAST,SMART,PROXY,CFAPP,OTEL,JPROC computeFill
class SN,SB storageFill
class SN_BLOB,SN_TAB,SB_BLOB,SB_TAB storageFill
Notes
- The Azure Container Registry (ACR) and resource group (infra-rg) are shown in a separate subscription as that is how they are deployed at Merkalis. Having an ACR is a deployment prerequisite.
- The VNet is segmented into 2 subnets (compute and storage). The diagram denotes which subnet each component is a member of by using matching colors.
- All communications on the compute subnet are secured by mTLS.
- All communications to/from the storage subnets are secured by TLS 1.2
Modules
Documentation for the Merkalis Azure modules is available below. Each page covers the module's features, prerequisites, inputs, outputs, and example usage.
| Module | Description |
|---|---|
| Networking | Provisions a Virtual Network (vNet) and subnets with consistent FinOps tagging |
| Secrets | Provisions a purge-protected Azure Key Vault with RBAC and a reader managed identity |
| Object Store | Provisions sharded Azure Storage Accounts for Blob object storage with mirrored Azure Tables |
| File Store | Provisions sharded Azure Storage Accounts with SMB file shares encrypted at rest and in transit |
| Queue | Provisions a dedicated, key-disabled Azure Storage Account with a single queue for Notification Events |
| Compute | Provisions a shared Container App Environment with container apps and jobs |
| Gateway | Provisions the reverse proxy entrypoint, with optional Cloudflare Tunnel and mTLS proxy |
| Telemetry | Provisions an OpenTelemetry collector container app with optional Grafana Cloud pipelines |
How the module outputs and inputs wire together — and the effective build order — is documented in Module Dependencies.
A complete, hand-composed configuration that provisions the sample deployment
above — including the full main.tf and a walk-through of every wire — lives
in Sample Deployment.
Azure Module Dependencies
The Merkalis Azure modules are designed for provisioning the components of a complete Kastoria environment. This page records which module outputs are consumed as inputs by which other modules, so that wiring and build order are explicit rather than implicit.
Module inventory
| Module | Role |
|---|---|
| networking | Provisions the VNet and its subnets (compute / storage / gateway) |
| secrets | Provisions a Key Vault and a reader user-assigned identity |
| filestore | Provisions SMB file shares, one storage account per shard |
| objectstore | Provisions Blob + table storage, one storage account per shard |
| queue | Provisions a Storage Queue on a dedicated key-disabled account (optional) |
| compute | Provisions the Container App Environment with container apps and jobs |
| gateway | Provisions the reverse proxy entrypoint, with optional Cloudflare Tunnel and mTLS proxy |
| telemetry | Provisions the OTel collector container app and Grafana datasource |
An environment also defines a user-assigned storage-accessor identity that no module produces; it feeds the storage and compute modules (see Storage identity).
Dependency graph
graph TD
RG[Resource Group]
ID[User Managed Identity<br/>storage accessor]
NET[networking]
SEC[secrets]
FILE[filestore]
OBJ[objectstore]
COMP[compute]
GW[gateway]
TEL[telemetry]
QUEUE[queue]
RG --> NET & SEC & FILE & OBJ & COMP & GW & TEL & QUEUE
ID --> FILE & OBJ & COMP
NET -->|subnet ids| SEC & FILE & OBJ & COMP & QUEUE
QUEUE -->|storage_account_name, queue_name, publisher_id, publisher_client_id| COMP
SEC -->|vault_uri, identity_id| COMP
SEC -->|vault_id, vault_name, identity_id| GW
FILE -->|file_shares_config, access keys| COMP
COMP -->|environment id, default domain, identity_id| GW
COMP -->|environment id, identity_id| TEL
Output → input edges
networking
| Output | Consumer | Input | Notes |
|---|---|---|---|
created_subnets["compute"].id |
secrets | allowed_subnet_ids |
Key Vault service-endpoint ACL |
| filestore | allowed_subnet_ids |
shared with storage |
|
| objectstore | allowed_subnet_ids |
shared with storage |
|
| queue | allowed_subnet_ids |
the only subnet the queue admits | |
| compute | subnet_id |
ACA VNet integration | |
created_subnets["storage"].id |
filestore | allowed_subnet_ids |
|
| objectstore | allowed_subnet_ids |
||
created_subnets["gateway"].id |
compute | gateway_subnet_id |
Subnet names are not invented by the module — the caller declares the set of
subnets, and networking mirrors them back in created_subnets, keyed by
subnet name (compute / storage / gateway in the standard layout).
secrets
| Output | Consumer | Input | Notes |
|---|---|---|---|
vault_uri |
compute | key_vault_uri |
for key_vault_secrets per app/job |
identity_id |
compute | key_vault_reader_identity_id |
reader identity attached to apps/jobs reading Key Vault |
| gateway | user_assigned_identity_id |
proxy reads the vault for the CA cert + tunnel token | |
vault_id |
gateway | key_vault_id |
|
vault_name |
gateway | key_vault_name |
filestore
| Output | Consumer | Input | Notes |
|---|---|---|---|
file_shares_config |
compute | apps[].share_mounts, jobs[].share_mounts |
non-sensitive share metadata |
file_shares[].access_key |
compute | storage_access_keys |
delivered through a separate sensitive map |
The two paths are deliberately separate: the non-sensitive file_shares_config
drives for_each on apps/jobs, while the sensitive access_key values travel
through the dedicated storage_access_keys map so they do not taint for_each
keys downstream.
Note: These outputs are only indirectly consumed by compute
queue
| Output | Consumer | Input | Notes |
|---|---|---|---|
storage_account_name |
compute | app_config.queue_writer |
accountName in the JSON-encoded queueWriter section |
queue_name |
compute | app_config.queue_writer |
queueName in the same section |
publisher_id |
compute | apps[].user_managed_queue_app.id, jobs[].user_managed_queue_app.id |
attached to the workloads that publish |
publisher_client_id |
compute | same, and app_config.queue_writer |
managedIdentityClientId in the queueWriter section |
publisher_principal_id |
— | — | the Sender grant target, for post-apply inspection |
queue_url |
— | — | handed to your queue reader |
storage_account_id |
— | — | currently unconsumed |
The queue module owns its publisher identity end to end — it creates it,
grants it Storage Queue Data Message Sender, and exports it — the same shape
secrets uses for its reader identity. You decide which workloads receive it,
just as you decide which receive the storage-accessor identity. The queue is
optional: omit the module and leave queue_writer unset.
Note: These outputs are only indirectly consumed by compute
compute
| Output | Consumer | Input | Notes |
|---|---|---|---|
container_app_environment_id |
gateway | container_app_environment_id |
proxy provisions into the same ACA environment |
| telemetry | container_app_environment_id |
OTel collector provisions into the same environment | |
container_app_environment_default_domain |
gateway | domain |
overridable per environment |
identity_id |
gateway | registry_identity_id |
ACR pull auth for proxy images |
| telemetry | registry_identity_id |
ACR pull auth for the collector image | |
identity_principal_id |
— | — | consumed at the environment level for ACR RBAC |
app_fqdns |
— | — | currently unconsumed |
compute depends almost entirely on its own configuration rather than on
module outputs: the caller supplies apps, jobs, storage_access_keys,
app_config, key_vault_uri, key_vault_reader_identity_id, and the subnet
IDs.
Storage identity
The environment defines one user-assigned managed identity that is not owned by any module. It is the identity Kastoria uses to reach storage without account keys, and it feeds three places:
filestore.user_managed_identity_principal_id→ share-access role assignmentobjectstore.user_managed_identity_principal_id→ blob + table data contributor role assignmentscomputeapps/jobs asuser_managed_storage_app, so workloads authenticate to storage with the identity rather than connection strings
It does not feed queue: holding the storage identity grants no access to the
queue. A workload that publishes carries the queue module's publisher identity
as well (see queue).
Leaf modules
objectstore, gateway, and telemetry produce no inputs to other modules in
the composition:
| Module | Outputs | Consumed by |
|---|---|---|
objectstore |
storage_accounts, tables |
environment-side wiring only — workload access is via RBAC + app_config.storage_tiers |
gateway |
container_app_*, cloudflared_*, cloudflare_tunnel_*, mtls_proxy_* |
nothing — terminal |
telemetry |
grafana_datasource_uid |
nothing — terminal |
Ordering
Terraform/OpenTofu derives apply order from references, but the effective levels are:
- Resource group + storage-accessor identity
networkingsecrets,filestore,objectstore,queue(in parallel)compute(needs the subnets, vault URI/reader identity, share metadata + keys, and the queue account, name and publisher identity whenqueue_writeris set)gateway,telemetry(need the Container App Environment id)
On a fresh subscription, also declare an explicit depends_on from compute
to the Microsoft.App resource-provider registration and the storage-accessor
identity — neither is a data reference, so without them the ordering would be
implicit and racy.
Azure Networking Module
This OpenTofu module provisions a Virtual Network (vNet) and multiple subnets within a specified Azure Resource Group. It is designed with FinOps in mind, allowing for consistent tagging across resources for cost allocation and management.
Features
- Dynamic creation of subnets using a map object.
- Standardized naming conventions.
- Support for Service Endpoints.
- FinOps Ready: Integrated tagging for the Virtual Network.
Requirements
No requirements.
Providers
| Name | Version |
|---|---|
| azurerm | 4.81.0 |
Modules
No modules.
Resources
| Name | Type |
|---|---|
| azurerm_subnet.subnets | resource |
| azurerm_virtual_network.vnet | resource |
Inputs
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| address_space | The address space for the vNet as a CIDR | list(string) |
n/a | yes |
| base_name [d] | The base name for the networking resources | string |
n/a | yes |
| location [d] | The location of the networks | string |
n/a | yes |
| resource_group_name [d] | The name of the resource group for the networks | string |
n/a | yes |
| subnets [d] | Definition for the subnets | map(object({ |
n/a | yes |
| tags | A map of tags to apply to the resources. | map(string) |
n/a | yes |
[d] Destructive: changing this input forces one or more resources to be destroyed and recreated (an OpenTofu/Terraform replacement) rather than updated in place.
Outputs
| Name | Description |
|---|---|
| created_subnets | Map of subnet details |
| vnet_id | The ID of the virtual network |
Example Usage
module "networking" {
source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/networking?tag=<module-version>"
base_name = "app-prod"
location = "East US"
resource_group_name = "rg-prod-networking"
address_space = ["10.0.0.0/16"]
subnets = {
frontend = {
address_prefixes = ["10.0.1.0/24"]
service_endpoints = ["Microsoft.Storage"]
},
backend = {
address_prefixes = ["10.0.2.0/24"]
service_endpoints = ["Microsoft.Sql"]
}
}
tags = {
Environment = "Production"
CostCenter = "IT-101"
Owner = "CloudOps"
Project = "Skyline"
}
}
Notes
Azure subnets do not support individual tags; they are typically tracked via the parent Virtual Network or Resource Group.
Available versions
v0.9.1
Azure Key Vault Secrets Module
This OpenTofu module provisions an Azure Key Vault with configurable purge protection/soft delete retention and RBAC authorization, a User-Assigned Managed Identity with read access to secrets, and network ACLs for secure access.
Features
- Key Vault: Both purge protection and soft delete retention are configurable, RBAC authorization enabled
- Reader Identity: User-assigned managed identity granted
Key Vault Secrets Userrole - Network ACLs: Subnet service endpoints + optional IP allowlist; Azure Services bypass enabled
- Tagging: Standard
submodule = "Secrets"tag applied
Requirements
| Name | Version |
|---|---|
| azurerm | ~> 4.0 |
| random | ~> 3.0 |
Providers
| Name | Version |
|---|---|
| azurerm | ~> 4.0 |
Modules
No modules.
Resources
| Name | Type |
|---|---|
| azurerm_key_vault.vault | resource |
| azurerm_role_assignment.reader_secrets | resource |
| azurerm_user_assigned_identity.reader | resource |
| azurerm_client_config.current | data source |
Inputs
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| allowed_ips | List of IPs to whitelist for Key Vault access | list(string) |
[] |
no |
| allowed_subnet_ids | The IDs of the subnets that can access the Key Vault (service endpoints) | list(string) |
n/a | yes |
| base_name [d] | The base name prefix used for resources | string |
n/a | yes |
| location [d] | Azure region | string |
n/a | yes |
| purge_protection_enabled | Enable purge protection (requires support ticket to purge deleted secrets) | bool |
false |
no |
| resource_group_name [d] | The name of the resource group | string |
n/a | yes |
| sku_name | The SKU of the Key Vault (standard or premium) | string |
"standard" |
no |
| soft_delete_retention_days | Days to retain soft-deleted secrets | number |
7 |
no |
| suffix [d] | A suffix to append to the keyvault name. | string |
n/a | yes |
| tags | Tags to apply to resources | map(string) |
{} |
no |
[d] Destructive: changing this input forces one or more resources to be destroyed and recreated (an OpenTofu/Terraform replacement) rather than updated in place.
Outputs
| Name | Description |
|---|---|
| identity_client_id | The client ID of the reader identity |
| identity_id | The resource ID of the user-assigned identity with read access |
| identity_principal_id | The principal ID of the reader identity, used for further RBAC assignments |
| vault_id | The resource ID of the Key Vault |
| vault_name | The name of the Key Vault |
| vault_uri | The URI of the Key Vault |
Example Usage
module "secrets" {
source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/secrets?tag=<module-version>"
base_name = "myapp-production"
suffix = "1a2b"
resource_group_name = "rg-myapp-production"
location = "eastus"
allowed_subnet_ids = ["/subscriptions/.../subnets/my-subnet"]
allowed_ips = ["203.0.113.1/32"]
sku_name = "standard"
tags = {
Environment = "Production"
Owner = "DevOps"
}
}
Resources Created
azurerm_key_vault— Key Vault with a (default) soft-delete retention of 7 days, RBAC auth, network ACLsazurerm_user_assigned_identity— Reader identity scoped to the vaultazurerm_role_assignment—Key Vault Secrets Userrole on the vault for the identity
Network Requirements
The subnets in allowed_subnet_ids must have the Microsoft.KeyVault service endpoint enabled:
resource "azurerm_subnet" "example" {
service_endpoints = ["Microsoft.KeyVault"]
}
The vault uses default_action = "Deny" with bypass = "AzureServices" so trusted Azure platform services can still reach it.
Security Considerations
1. If purge protection is enabled it cannot be disabled after creation
2. RBAC is used instead of access policies
3. The created reader identity gets read-only access (
Key Vault Secrets User); write access requires separate role assignment4. Network ACLs deny all traffic by default; only subnets in
allowed_subnet_idsand IPs inallowed_ipscan reach the vault5. Because network isolation is enforced by denying access to all non-whitelisted IPs, Terraform/OpenTofu plans/applies will fail (in the survey phase) with
403 Forbiddenif the Terraform/OpenTofu runner's IP address is not contained inallowed_ips.
Available versions
v0.9.1
Azure Blob ObjectStore Module
This OpenTofu module provisions Standard Azure Storage Accounts (General Purpose v2) for Blob object storage, with a mirrored Azure Table for every container.
It uses a "sharding" strategy: the top-level key of the shards map becomes one Storage Account, and the nested map under it names the private Blob containers inside that account. Sharding increases aggregate throughput and bounds the blast radius of a single account's limits. Each container also gets a matching Azure Table in the same account, named after the container with its dashes stripped, so blob and table access share one set of network rules and role assignments.
Features
- Standard Performance:
Standardtier withLRSreplication (General Purpose v2) for cost-effective object storage. - Blob Versioning: Enabled on every account, so an overwrite or soft-deleted blob stays recoverable.
- Sharding: Each top-level
shardskey becomes a dedicated Storage Account, and each account holds as many containers as its nested map lists — subject only to the 24-character account name budget. - Declared Containers: Nested map keys become private containers. An empty shard map synthesizes a single container named
data. - Table Storage Mirroring: Every container gets an
azurerm_storage_tablenamed after it with all-stripped, created in the same account. - Plan-Time Table Validation: Invalid or colliding table names abort the plan via a
terraform_dataprecondition rather than surfacing as an opaque Azure API error at apply time. - Managed Identity Access: Grants
Storage Blob Data ContributorandStorage Table Data Contributoron every account touser_managed_identity_principal_id, so workloads authenticate without connection strings. - Network Security: With
enable_firewall = true(the default) the accounts setdefault_action = "Deny", admitallowed_subnet_idsvia virtual network rules andallowed_ipsvia IP rules, and bypassAzureServices. With it off, the network rules block opens toAllowwith no restrictions. - HTTPS Enforcement:
https_traffic_only_enabled = trueandmin_tls_version = "TLS1_2"on every account. - Firewall Drift Tolerance:
network_rules[0].ip_rulesis inignore_changes, so the temporary runner-IP whitelists that CI/CD applies to these accounts do not show up as drift.
Requirements
No requirements.
Providers
| Name | Version |
|---|---|
| azurerm | 4.80.0 |
| terraform | n/a |
Modules
No modules.
Resources
| Name | Type |
|---|---|
| azurerm_role_assignment.umi_storage_access | resource |
| azurerm_role_assignment.umi_table_access | resource |
| azurerm_storage_account.object | resource |
| azurerm_storage_container.containers | resource |
| azurerm_storage_table.tables | resource |
| terraform_data.table_name_check | resource |
Inputs
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| allowed_ips | List of ips to whitelist | list(string) |
[] |
no |
| allowed_subnet_ids | The IDs of the subnets that can access the object store. | list(string) |
n/a | yes |
| base_name [d] | The base name prefix used for resources. | string |
n/a | yes |
| enable_firewall | Enables storage account firewall | bool |
true |
no |
| location [d] | The location where resources will be created. | string |
n/a | yes |
| resource_group_name [d] | The name of the resource group. | string |
n/a | yes |
| shards [d] | A map of shard keys (each will get its own storage account) | map(map(object({}))) |
n/a | yes |
| suffix [d] | A suffix to append to the resource name. | string |
n/a | yes |
| tags | A map of tags to apply to the resource. | map(string) |
n/a | yes |
| user_managed_identity_principal_id [d] | The principal ID of the user managed identity (required for role assignments). | string |
n/a | yes |
[d] Destructive: changing this input forces one or more resources to be destroyed and recreated (an OpenTofu/Terraform replacement) rather than updated in place.
Outputs
| Name | Description |
|---|---|
| storage_accounts | Map of object store details |
| tables | Map of tables created per shard, with IDs for downstream chaining |
Example Usage
module "object_store" {
source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/objectstore?tag=<module-version>"
base_name = "myapp"
suffix = random_id.project_prefix.hex
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
# Required for the virtual network rules
allowed_subnet_ids = [
module.networking.created_subnets["storage"].id,
module.networking.created_subnets["compute"].id
]
# Required: principal of the identity that workloads use to reach the data
user_managed_identity_principal_id = azurerm_user_assigned_identity.app_identity.principal_id
# Shards; nested keys are the containers within each account
shards = {
"sn1" = {
"hot-nodes-1" = {}
}
"sb1" = {
"hot-blocks-1" = {}
}
"sb2" = {} # empty -> a single container named "data"
}
# The runner's IP must be present for plan/apply to reach the accounts
allowed_ips = var.allowed_ips
tags = azurerm_resource_group.env.tags
}
Shards Variable Structure
shards is a map of shard keys to a map of container names. The shard key selects the
Storage Account, the inner keys become containers within it:
shards = {
"sb1" = {
"hot-blocks-1" = {}
"audit" = {}
}
"sb2" = {} # no containers declared -> synthesizes one container named "data"
}
The inner value is currently always an empty object — container names carry all the information the module needs, and per-container settings have nowhere to go yet.
To add a container to an existing shard, add the key: the new container and its mirrored table are created on the next apply. Removing a key destroys both. Renaming a key replaces the container, which is destructive.
Notes
1. Table Storage Mirroring
Every container (including the synthesized default data container) gets a matching azurerm_storage_table named after the container with all - stripped:
| Container name | Table name |
|---|---|
data |
data |
my-logs |
mylogs |
Tables are created in the same storage account (shard) as their container, so no shard prefix is added.
Azure table names must start with a letter and be 3-63 alphanumeric characters. This module strips - and validates the result against that rule at plan time, so a container name that would produce an invalid table name (e.g. starts with a digit or is too short) fails fast with a precondition error — before anything is applied.
Collisions: If two containers within the same shard strip to the same table name (e.g. my-data and mydata), the plan fails with a precondition error listing the offending shard and table name. Rename the containers to resolve.
Existing environments: New tables are empty. If a same-named table already exists in a storage account (e.g. created manually), the apply will conflict. Import the existing table first:
tofu import 'module.object_store.azurerm_storage_table.tables["<shard_key>-<container_name>"]' 'https://<storage-account>.table.core.windows.net/Tables('\''<table-name>'\'')'
2. Naming Limitations
Azure Storage Account names are strictly limited to 24 characters and must be lowercase alphanumeric. This module constructs names using the pattern:
sa + base_name + suffix + shard_key
Only the sa + base_name + suffix prefix is scrubbed to lowercase alphanumerics; the
shard_key is concatenated verbatim, so shard keys must themselves be lowercase
alphanumeric — sb01 is fine, hot-blocks-01 produces an invalid name.
Warning: You must ensure your inputs are short enough.
- Prefix: sa (2 chars)
- Base: myapp (5 chars)
- Suffix: hex id (4 chars)
- Key: sb01 (4 chars)
- Total: 15/24 chars (Safe)
An over-long name is silently truncated by substr(..., 0, 24) rather than rejected at
plan time. Two shards whose keys share a prefix past the 24th character therefore collapse
onto the same account name and the apply fails with an "already exists" conflict — keep the
distinctive part of each shard key within the first 24 characters.
3. Secure Transfer Requirements
All storage accounts are configured to:
- Require HTTPS: HTTP traffic is rejected (https_traffic_only_enabled = true)
- Use TLS 1.2+: Minimum TLS version is set to 1.2 for encryption in transit
4. Managed Identity Access
user_managed_identity_principal_id is a required input and creates two role assignments
per shard:
- Storage Blob Data Contributor for the container/blob data
- Storage Table Data Contributor for the mirrored tables
- This enables passwordless authentication using Entra ID.
- The identity must exist in the same or a trusted Entra tenant.
The storage_accounts output still exposes primary_conn_string and primary_access_key
(the whole map is marked sensitive). Prefer the identity-based access above; the keys are
there for bootstrap and tooling scenarios, and nothing in this repo currently reads them.
5. Network Access
With the default enable_firewall = true, the module sets default_action = "Deny" on the
network rules:
- Access is only allowed from allowed_subnet_ids.
- Access is also allowed for trusted AzureServices via bypass.
- Access via Managed Identity still requires the workload to be inside the allowed subnet or otherwise have appropriate network routing — the identity authorizes the request, the firewall decides the path.
- For the Azure Portal or an on-prem client, add its egress IP to allowed_ips.
Because network isolation is enforced by denying access to all non-whitelisted IPs, plan and
apply fail (during the survey phase) with 403 Forbidden if the runner's IP address is not
contained in allowed_ips. The CI/CD workflows whitelist the runner IP temporarily and rely
on ignore_changes over network_rules[0].ip_rules so those one-off additions never appear
as drift.
Setting enable_firewall = false publishes the accounts to the whole internet
(default_action = "Allow" with no subnet, IP, or bypass rules) — intended only for
environments (e.g., temporary performance test environments) where isolation is either
not needed or handled elsewhere.
Available versions
v0.9.1
Azure SMB FileStore Module
This OpenTofu module provisions Azure Storage Accounts with SMB file shares, designed for secure, scalable file storage with encryption at rest and in transit.
Features
- Encryption at Rest: All data automatically encrypted using Azure Storage Service Encryption (SSE) with 256-bit AES
- Encryption in Transit: HTTPS enforcement, TLS 1.2+, and SMB 3.0 protocol-level encryption
- Multi-shard Architecture: Each shard gets its own dedicated storage account for isolation and scalability
- Network Security: VNet service endpoints and IP whitelisting to restrict access
- Azure Integration: Seamless integration with Azure Container Apps, VMs, and Kubernetes
Requirements
No requirements.
Providers
| Name | Version |
|---|---|
| azurerm | 4.77.0 |
Modules
No modules.
Resources
| Name | Type |
|---|---|
| azurerm_role_assignment.umi_share_access | resource |
| azurerm_storage_account.smb | resource |
| azurerm_storage_share.smb_shares | resource |
Inputs
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| allowed_ips | List of IPs to whitelist | list(string) |
[] |
no |
| allowed_subnet_ids | The IDs of the subnets that can access the file store. | list(string) |
n/a | yes |
| base_name [d] | The base name prefix used for resources. | string |
n/a | yes |
| enable_firewall | Enables storage account firewall | bool |
true |
no |
| location [d] | The location where resources will be created. | string |
n/a | yes |
| resource_group_name [d] | The name of the resource group. | string |
n/a | yes |
| shards [d] | A map of SMB shard objects (each will get its own storage account) | map(object({ |
n/a | yes |
| suffix [d] | A suffix to append to the resource name. | string |
n/a | yes |
| tags | A map of tags to apply to the resource. | map(string) |
n/a | yes |
| user_managed_identity_principal_id [d] | The principal ID of the user managed identity (required for role assignments). | string |
n/a | yes |
[d] Destructive: changing this input forces one or more resources to be destroyed and recreated (an OpenTofu/Terraform replacement) rather than updated in place.
Outputs
| Name | Description |
|---|---|
| file_shares | Map of SMB file share details |
| file_shares_config | Map of SMB file share metadata (non-sensitive, safe for for_each) |
| smb_connection_strings | SMB connection strings for mounting shares |
Example Usage
module "smb_filestore" {
source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/filestore?tag=<module-version>"
base_name = "myapp"
suffix = "prod"
location = "eastus"
resource_group_name = azurerm_resource_group.main.name
user_managed_identity_principal_id = "00000000-0000-0000-0000-000000000000"
allowed_subnet_ids = [
azurerm_subnet.container_apps.id
]
allowed_ips = [
"203.0.113.0/24" # Office network
]
shards = {
"media" = {
quota_gb = 1024
}
"uploads" = {
quota_gb = 512
}
}
tags = {
Environment = "production"
Application = "myapp"
}
}
Encryption Details
Encryption at Rest
- Technology: Azure Storage Service Encryption (SSE)
- Algorithm: 256-bit AES encryption
- Scope: All data, metadata, and snapshots
- Key Management: Microsoft-managed keys (default)
- Status: Always enabled, cannot be disabled
Encryption in Transit
- HTTPS: Enforced for all management operations (
https_traffic_only_enabled = true) - TLS Version: Minimum TLS 1.2 required (
min_tls_version = "TLS1_2") - SMB Protocol: SMB 3.0+ with built-in encryption for data transfer
- Network: All client-to-storage communication is encrypted
Mounting SMB Shares
To mount the SMB file shares into Azure Container Apps created using the compute/azure
module, you would configure the appropriate share_mounts object on the desired
apps/app jobs. (See the compute/azure module documentation.)
Mounting the share to a VM or Azure Container Apps created outside of the compute/azure
module is outside the file's scope. We refer you to Azure documentation for details on this.
Notes
1. Network Access
a. For security reasons this module enforces (sub-) network isolation by creating network rules with
default_action = "Deny"for each storage account created. - Access is only allowed from the specifiedsubnet_id. - Access is also allowed for trustedAzureServices. - Access via Managed Identity requires the identity to be running within the allowed subnet or have appropriate network routing. - You will not be able to access the data from the Azure Portal unless your client IP is added to the firewall or you are accessing it from a VM within the allowed subnet.b. Because network isolation is enforced by denying access to all non-whitelisted IPs, Terraform/OpenTofu plans/applies will fail (in the survey phase) with
403 Forbiddenif the Terraform/OpenTofu runner's IP address is not contained inallowed_ips.
Troubleshooting
Port 445 Blocked
Issue: Cannot connect to SMB share
Solution: Ensure port 445 (SMB) is open in your network/firewall. Many ISPs block port 445.
Access Denied
Issue: Authentication failures
Solution:
- Verify storage account name and access key are correct
- Check that client IP is in allowed_ips or subnet is in allowed_subnet_ids
- Ensure SMB 3.0+ is supported on the client
Performance Issues
Issue: Slow file access
Solution:
- Use VNet service endpoints for better performance within Azure
- Consider Premium tier for higher IOPS requirements
- Check quota limits on shares
Available versions
v0.9.1
Azure Storage Queue Module
This OpenTofu module provisions a dedicated Azure Storage Account holding a single Azure Storage Queue, for the Notification Events that kastoria-eventnotification publishes. The events themselves are documented in Ingestion Manifest.
The account holds nothing but the queue. Its consumer is a customer-developed queue reader, so keeping it separate from the blob and table accounts means a reader's role assignment lands on an account with no PHI to mis-scope it onto, and the queue's network posture stays independent of the object store's.
Features
- Standard Performance:
Standardtier withLRSreplication (General Purpose v2), as the queue is a transient work list rather than a store of record. - One Account, One Queue: Unlike
objectstoreandfilestorethere is noshardsmap. A second consumer gets a second account, which keeps every reader's grant scoped to exactly one queue. - No Credentials:
shared_access_key_enabled = false, so the account has no keys and no SAS can be minted from it. Every caller authenticates with Entra, and nothing is handed over, rotated or expired. - Managed Identity Publishing: Creates its own publisher identity,
umi-<base_name>-queue-publisher, and grants itStorage Queue Data Message Senderon the account: send only, never read, peek or manage. OpenTofu creates the queue, so the publisher never needs to, and runs withcreateQueueIfMissingoff. The identity exists for exactly this queue and holds no rights on any other store, so workloads that reach object storage cannot reach the queue unless you also attach the publisher to them. Supplyuser_managed_identity_principal_idinstead to bring your own principal and skip creating one. - Customer Reader Access: Grants
Storage Queue Data Message Processorto each entry ofreader_principal_ids, so a customer's reader peeks, gets and deletes messages without a secret changing hands. - Plan-Time Name Validation: The derived account name is checked against
^[a-z0-9]{3,24}$by aterraform_dataprecondition — the same rule the Kastoria queue writer validatesaccountNamewith — so a badbase_name/suffixaborts the plan rather than failing at apply. - Queue Name Validation:
queue_nameis checked against Azure's rule (3-63 lowercase alphanumerics and hyphens, starting and ending alphanumeric, no double hyphen) at plan time. - Network Security: With
enable_firewall = true(the default) the account setsdefault_action = "Deny", admitsallowed_subnet_idsvia virtual network rules andallowed_ipsvia IP rules, and bypassesAzureServices. With it off, the network rules block opens toAllowwith no restrictions. - HTTPS Enforcement:
https_traffic_only_enabled = trueandmin_tls_version = "TLS1_2". - Firewall Drift Tolerance:
network_rules[0].ip_rulesis inignore_changes, so the temporary runner-IP whitelists that CI/CD applies to these accounts do not show up as drift.
Requirements
| Name | Version |
|---|---|
| azurerm | ~> 4.0 |
Providers
| Name | Version |
|---|---|
| azurerm | ~> 4.0 |
| terraform | n/a |
Modules
No modules.
Resources
| Name | Type |
|---|---|
| azurerm_role_assignment.readers | resource |
| azurerm_role_assignment.writer | resource |
| azurerm_storage_account.queue | resource |
| azurerm_storage_queue.events | resource |
| azurerm_user_assigned_identity.publisher | resource |
| terraform_data.account_name_check | resource |
Inputs
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| allowed_ips | List of ips to whitelist. Unlike the sibling storage modules, this list also carries the customer reader's egress ranges. | list(string) |
[] |
no |
| allowed_subnet_ids | The IDs of the subnets that can access the queue. | list(string) |
n/a | yes |
| base_name [d] | The base name prefix used for resources. | string |
n/a | yes |
| enable_firewall | Enables storage account firewall | bool |
true |
no |
| location [d] | The location where resources will be created. | string |
n/a | yes |
| queue_name [d] | The queue Notification Events are published to. | string |
"kastoria-events" |
no |
| reader_principal_ids [d] | Principal IDs granted Storage Queue Data Message Processor, for customer-developed queue readers. | list(string) |
[] |
no |
| resource_group_name [d] | The name of the resource group. | string |
n/a | yes |
| suffix [d] | A suffix to append to the resource name. | string |
n/a | yes |
| tags | A map of tags to apply to the resource. | map(string) |
n/a | yes |
| user_managed_identity_principal_id [d] | Principal ID to grant Storage Queue Data Message Sender. Null (default) creates a dedicated publisher identity and grants that instead. | string |
null |
no |
[d] Destructive: changing this input forces one or more resources to be destroyed and recreated (an OpenTofu/Terraform replacement) rather than updated in place.
Outputs
| Name | Description |
|---|---|
| publisher_client_id | Client ID of the created publisher identity, for the configuration document's managedIdentityClientId. Null when user_managed_identity_principal_id is supplied |
| publisher_id | Resource ID of the created publisher identity, for a workload's identity_ids. Null when user_managed_identity_principal_id is supplied |
| publisher_principal_id | Principal ID of the publisher: the created identity, or the supplied user_managed_identity_principal_id |
| queue_name | Name of the queue Notification Events are published to |
| queue_url | Data plane URL of the queue, for a customer-developed reader |
| storage_account_id | Resource ID of the queue storage account |
| storage_account_name | Name of the queue storage account, which is the accountName the Kastoria queue writer takes |
Example Usage
module "queue" {
source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/queue?tag=<module-version>"
base_name = "myapp"
suffix = random_id.project_prefix.hex
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
# Only the compute subnet publishes; nothing else in the VNet reads this queue
allowed_subnet_ids = [
module.networking.created_subnets["compute"].id
]
# Principal IDs of your queue reader(s), granted read/process access
reader_principal_ids = []
# The runner's IP must be present for plan/apply to reach the account.
# Append your reader's egress ranges if it runs outside the VNet
allowed_ips = var.allowed_ips
tags = azurerm_resource_group.env.tags
}
The module creates its own publisher identity and exports it (publisher_id, publisher_client_id, publisher_principal_id); attach it to whichever workloads should publish. To grant the Sender role to a principal you already own instead, pass user_managed_identity_principal_id — the module then creates no identity, publisher_id and publisher_client_id are null, and you wire the identity and managedIdentityClientId yourself.
Wiring the Queue Writer
Two things connect the queue to Kastoria: the publishing workload must carry the publisher identity, and the configuration document must name the queue and that identity.
Attach the publisher to the job through the compute module's user_managed_queue_app:
jobs = {
evtnotify = {
# ... container_config, schedule, tags ...
user_managed_storage_app = { id = azurerm_user_assigned_identity.app_identity.id, client_id = azurerm_user_assigned_identity.app_identity.client_id }
user_managed_queue_app = { id = module.queue.publisher_id, client_id = module.queue.publisher_client_id }
}
}
The event notification job reads the Changelog from object storage as well as publishing, so it carries both identities.
Kastoria learns about the queue through an optional queue_writer entry in app_config. Like system_storage and storage_tiers, it is a JSON-encoded string, and it renders as the queueWriter section of the Kastoria configuration document:
app_config = {
organization = "My Org"
environment_name = "myapp-prod"
purpose = "production"
system_storage = jsonencode([])
storage_tiers = local.storage_tiers
queue_writer = jsonencode({
type = "azure-storage-queue"
configuration = {
accountName = module.queue.storage_account_name
managedIdentityClientId = module.queue.publisher_client_id
queueName = module.queue.queue_name
messageEncoding = "text" # or "base64"
}
})
}
- No connection string. The account has no keys to build one from, and the writer refuses a
connectionStringalongsidemanagedIdentityClientId. - No
createQueueIfMissing. OpenTofu owns the queue, which is what lets the publisher's role stayStorage Queue Data Message Sender. managedIdentityClientIdis the publisher's client ID. It is not theAZURE_CLIENT_IDthatuser_managed_storage_appsets. A workload carrying both identities usesAZURE_CLIENT_ID(the storage identity) for the storage tiers, and the explicitmanagedIdentityClientId(the publisher) for the queue.- The publishing job must carry the publisher.
user_managed_queue_appattaches it without setting any environment variable; without it the writer cannot authenticate as the publisher and every publish fails. messageEncodingistext(the JSON as-is) orbase64. Every message on the queue uses the same encoding, so choose the one your reader expects — an Azure Functions queue trigger expectsbase64by default.
Leaving queue_writer unset (it defaults to null) renders a configuration document identical to one without a queue.
The Kastoria writer composes the queue endpoint itself as https://<accountName>.queue.core.windows.net/<queueName>; there is no endpoint override.
Notes
1. No Credentials Exist
shared_access_key_enabled = false removes the account keys, which removes SAS along with them — an account SAS is signed with an account key. There is therefore nothing this module can emit that grants access, and no output is marked sensitive. Access is entirely Entra role assignments:
| Principal | Role | Granted via |
|---|---|---|
| The publishing workload | Storage Queue Data Message Sender |
the module-created umi-<base_name>-queue-publisher, unless user_managed_identity_principal_id names another principal |
| A customer's queue reader | Storage Queue Data Message Processor |
reader_principal_ids |
Both assignments are scoped to the storage account rather than the queue. azurerm_storage_queue exports no resource_manager_id, so unlike azurerm_storage_table there is no queue-scoped ARM id to target — and with one queue in the account the two scopes grant the same thing.
A reader that is not an Entra principal (for example, a tool that only accepts a SAS or connection string) is not supported.
2. Network Access
RBAC decides who may read; the firewall decides from where. A role assignment alone is not enough: a customer reader outside the VNet also needs its egress ranges admitted, or it receives a 403 Forbidden from the firewall that no role can resolve.
In objectstore and filestore, allowed_ips is operator access only. Here it also carries the reader's egress ranges, so keep the two lists separately named in your environment and concat them when passing allowed_ips.
3. Firewall Rules Are Seeded Once, Then Managed Out of Band
network_rules[0].ip_rules is in ignore_changes, so allowed_ips seeds the initial rule set only. The guard is not optional: creating and reading the queue are requests against the account's queue endpoint (https://<account>.queue.core.windows.net/<queue>), which sits behind this firewall even though queue creation authorizes as a management action rather than a data action. Plan and apply therefore fail with 403 Forbidden if the runner's IP is not admitted. CI/CD workflows whitelist the runner IP temporarily; without the guard every plan would show a spurious diff, and an apply would strip the runner's own IP while it still had work to do.
After the first apply, a reader's egress range is added out of band:
az storage account network-rule add -g <rg> --account-name <account> --ip-address <cidr>
Adding it to allowed_ips afterwards is harmless but has no effect — the guard discards it, and the plan will not show the change.
4. Onboarding a Reader
- Add the reader's Entra principal ID to
reader_principal_idsand apply. This creates theStorage Queue Data Message Processorrole assignment. - If the reader runs outside the allowed subnets, add its egress range out of band as shown above.
- Hand the reader the
storage_account_name,queue_nameandqueue_urloutputs, along with the message encoding you configured.
Until a reader is added, the queue is closed to everyone but the publisher.
5. CI Firewall Registration, and the First Apply Failing
If your pipeline whitelists its runner IP per storage account before a plan, add this account to that list once it exists, or plans will fail with a 403 against it. It cannot be added beforehand, because the rule cannot be set on an account that does not exist.
Unless the runner's IP is already in allowed_ips, this means the apply that first creates the account is expected to fail:
- The pipeline whitelists the runner IP only on accounts already in its list. The queue account is not in that list, because it does not exist yet.
azurerm_storage_account.queueis created — a control-plane call, unaffected by the account's own firewall — withdefault_action = "Deny"and no runner IP inip_rules.azurerm_storage_queue.eventsthen cannot reach the account and fails. The account is left in state.- Add the account to the pipeline's list and run again. The runner IP is now whitelisted before the plan, and the queue is created.
No rollback is needed between the two runs: the queue references the account by ID and nothing else depends on the queue existing.
6. Deploying Principal
The principal running OpenTofu needs Microsoft.Storage/storageAccounts/queueServices/queues write (and delete, for destroy). Creating a queue is a management action, not a data action — the data actions are the message-level operations under …/queues/messages/…, which is what the workload roles in Note 1 grant, and why this one is easy to overlook.
Subscription Owner covers it. Where the deployer is not an Owner, the smallest built-in role that does is Storage Queue Data Contributor; Storage Queue Data Message Sender and Storage Queue Data Message Processor cover only message-level operations.
Grant any such role alongside your other deployer-role assignments, not in this module. Component modules grant roles only to workload identities.
7. Naming Limitations
Azure Storage Account names are strictly limited to 24 characters and must be lowercase alphanumeric. This module constructs the name using the pattern:
saq + base_name + suffix
The whole name is scrubbed to lowercase alphanumerics and truncated to 24 characters, then validated at plan time. An over-long base_name is silently truncated rather than rejected.
Available versions
v0.9.1
Azure Container Apps Module
This OpenTofu module provisions a shared Azure Container App Environment along with one or more Azure Container Apps/App Jobs, using a User-Assigned Managed Identity to authorize Azure Container Registry (ACR) access.
Features
- Multi-App Support: Deploy multiple container apps (app jobs) in a single environment
- Managed Identity Authentication: Secure ACR access without storing credentials
- VNET Integration: Deploy into existing subnet for network isolation
- Flexible Ingress: Optional external/internal ingress configuration per app
- Customizable Workload Profiles: Support for Consumption and Dedicated profiles
- Tagging Strategy: Global and per-app/app job tag support
Prerequisites
- Existing Azure Container Registry (ACR)
- Existing Virtual Network with available subnet
- Appropriate Azure permissions to create resources
Requirements
| Name | Version |
|---|---|
| azapi | 2.7.0 |
| azuread | ~> 3.0 |
| azurerm | ~> 4.0 |
| random | ~> 3.0 |
Providers
| Name | Version |
|---|---|
| azapi | 2.7.0 |
| azuread | 3.8.0 |
| azurerm | 4.60.0 |
Modules
No modules.
Resources
| Name | Type |
|---|---|
| azapi_resource.auths | resource |
| azuread_application.apps | resource |
| azuread_application_password.app_secrets | resource |
| azuread_service_principal.apps | resource |
| azurerm_container_app.apps | resource |
| azurerm_container_app_environment.env | resource |
| azurerm_container_app_environment_storage.share_storage | resource |
| azurerm_container_app_job.jobs | resource |
| azurerm_log_analytics_workspace.log_storage | resource |
| azurerm_storage_account.auth_tokens_account | resource |
| azurerm_storage_container.auth_tokens | resource |
| azurerm_user_assigned_identity.aca_identity | resource |
| azurerm_storage_account_blob_container_sas.auth_storage_sas | data source |
Inputs
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| acr_login_server | The login server URL of the ACR (e.g., myacr.azurecr.io) | string |
n/a | yes |
| additional_workload_profiles | Additional workload profiles for the Container App Environment, in addition to the always-created Consumption profile | map(object({ |
{} |
no |
| app_config | The app config file | object({ |
n/a | yes |
| app_secrets | The configurations for the apps | map(object({ |
n/a | yes |
| apps [d] | The configurations for the apps | map(object({ |
n/a | yes |
| base_name [d] | The base name used in naming resources | string |
n/a | yes |
| gateway_subnet_id | The ID of the subnet for the application gateway | string |
n/a | yes |
| jobs [d] | The configurations for the jobs | map(object({ |
{} |
no |
| key_vault_reader_identity_id | Resource ID of a user-assigned identity with Key Vault Secrets User on the vault; attached to apps/jobs that use Key Vault-referenced secrets | string |
null |
no |
| key_vault_uri | Base URI of the Key Vault for Key Vault-referenced secrets (e.g. https:// |
string |
null |
no |
| location [d] | Azure region | string |
n/a | yes |
| resource_group_name [d] | The name of the resource group to deploy into | string |
n/a | yes |
| storage_access_keys | Map of mount IDs to storage account access keys (sensitive, provided separately to avoid tainting for_each) | map(string) |
{} |
no |
| subnet_id [d] | The ID of the subnet for VNET integration | string |
n/a | yes |
| tags | Tags to apply to resources | map(string) |
{} |
no |
| tenant_id | Entra Tenant ID | string |
n/a | yes |
| workload_profile | Default workload profile name assigned to container apps and jobs | string |
"Consumption" |
no |
[d] Destructive: changing this input forces one or more resources to be destroyed and recreated (an OpenTofu/Terraform replacement) rather than updated in place.
Outputs
| Name | Description |
|---|---|
| app_fqdns | The FQDNs of the deployed applications |
| container_app_environment_default_domain | The default domain of the container app environment |
| container_app_environment_id | The ID of the compute container app environment |
| identity_id | The Resource ID of the Managed Identity, used for ACR registry authentication |
| identity_principal_id | The Principal ID of the Managed Identity, used for RBAC assignments |
Example Usage
Basic Example
module "container_apps" {
source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/compute?tag=<module-version>"
base_name = "myapp-prod"
resource_group_name = "rg-container-apps"
location = "eastus"
subnet_id = azurerm_subnet.aca_subnet.id
gateway_subnet_id = azurerm_subnet.gateway_subnet.id
acr_login_server = "myacr.azurecr.io"
tenant_id = data.azurerm_client_config.current.tenant_id
app_config = {
organization = "My Org"
environment_name = "myapp-prod"
purpose = "production"
system_storage = jsonencode([])
storage_tiers = jsonencode([{ name = "hot", blockStore = [{}] }])
}
app_secrets = {
api = { env_secrets = { API_DB_URL = "postgres://..." } }
}
apps = {
api = {
container_config = {
name = "api-container"
image = "myacr.azurecr.io/api:latest"
cpu = 0.5
memory = "1Gi"
}
ingress_config = {
external_enabled = true
target_port = 8080
transport = "http"
}
revision_mode = "Single"
tags = {
component = "api"
}
}
worker = {
container_config = {
name = "worker-container"
image = "myacr.azurecr.io/worker:latest"
cpu = 1.0
memory = "2Gi"
}
ingress_config = null # No ingress for background worker
revision_mode = "Single"
tags = {
component = "worker"
}
}
}
tags = {
environment = "production"
managed_by = "opentofu"
}
}
With Additional Workload Profiles
module "container_apps" {
source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/compute?tag=<module-version>"
base_name = "myapp-staging"
resource_group_name = "rg-container-apps"
location = "eastus"
subnet_id = azurerm_subnet.aca_subnet.id
gateway_subnet_id = azurerm_subnet.gateway_subnet.id
acr_login_server = "myacr.azurecr.io"
# tenant_id, app_config and app_secrets are required; see the basic example
tenant_id = data.azurerm_client_config.current.tenant_id
app_config = local.app_config
app_secrets = local.app_secrets
additional_workload_profiles = {
d4 = {
workload_profile_type = "D4"
minimum_count = 1
maximum_count = 2
}
}
apps = {
webapp = {
container_config = {
name = "webapp"
image = "myacr.azurecr.io/webapp:v1.2.3"
cpu = 2.0
memory = "4Gi"
}
workload_profile = "d4"
ingress_config = {
external_enabled = true
target_port = 443
transport = "http2"
}
revision_mode = "Multiple"
tags = {}
}
}
}
Apps Object Structure
Each entry in the apps map should have the following structure:
{
container_config = {
name = string # Container name
image = string # Full image path (e.g., myacr.azurecr.io/app:tag)
cpu = number # CPU cores (0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0)
memory = string # Memory (e.g., "0.5Gi", "1Gi", "2Gi", "4Gi")
min_replicas = optional(number, 1) # Minimum running replicas
max_replicas = optional(number, 5) # Maximum running replicas
dicomweb_app_name = optional(string, null) # Key of the DICOMweb app (accepted, not yet consumed)
webapi_app_name = optional(string, null) # Literal string used for API_BACKEND_URL / API_HOST_NAME
}
ingress_config = optional(object({
external_enabled = bool # true for external, false for internal only
target_port = number # Container port to expose
transport = optional(string, "auto") # "auto", "http", or "http2"
cors_app_name = optional(string, null) # Key of the app allowed as a CORS origin
}))
share_mounts = optional(map(object({
mount = string # Sub-path of /mnt where the share is mounted
name = string # SMB file share name
storage_account = string # Storage account hosting the share
access_key = optional(string, null) # Fallback key; var.storage_access_keys wins when set
quota_gb = number # Share quota in GB
})), {})
revision_mode = optional(string, "Single") # "Single" or "Multiple"
authentication = optional(object({
identifier_uri = optional(bool, null)
web = optional(bool, null)
global_validation = object({
unauthenticatedClientAction = string # e.g. "RedirectToLoginPage" or "Return401"
redirectToProvider = optional(string, null)
})
}), null) # Non-null creates an Entra app + ACA auth config
user_managed_storage_app = optional(object({
id = string # User-assigned identity resource ID attached for storage access
client_id = string # Its client ID, emitted as AZURE_CLIENT_ID
}), null)
user_managed_queue_app = optional(object({
id = string # Queue publisher identity resource ID, attached alongside the storage identity
client_id = string # Its client ID; not emitted as an env var
}), null)
otel_exporter = optional(string, null) # OTLP endpoint; non-null adds collector env vars
workload_profile = optional(string, null) # Environment workload profile name; falls back to var.workload_profile
env_vars = optional(map(string), {}) # plain env var name -> value
key_vault_secrets = optional(map(string), {}) # env var name -> Key Vault secret name
tags = map(string) # App-specific tags
}
Notes
1:
share_mountsonly declares which existing SMB share an app consumes — the share itself is created by the filestore module, andquota_gbis carried for type consistency with it. Mounts resolving to the samestorage_account+namepair collapse into oneazurerm_container_app_environment_storageregistration, so the access key for it comes fromvar.storage_access_keyswhen that is populated and from the mount's ownaccess_keyotherwise.2:
authenticationrequirestenant_id. When non-null it creates anazuread_application,azuread_service_principal, andazuread_application_passwordfor that app, and anazapi_resourceACA auth config that uses the module's auth-token blob container (auth-token-storage-sas) as its token store.identifier_uriandwebare declared but not consumed by the module.
Jobs Object Structure
Each entry in the jobs map should have the following structure:
{
container_config = {
name = string # Container name
image = string # Full image path (e.g., myacr.azurecr.io/job:tag)
cpu = number # CPU cores
memory = string # Memory (e.g., "1Gi", "2Gi")
args = optional(list(string), []) # Container arguments
}
share_mounts = optional(map(object({
mount = string # Sub-path of /mnt where the share is mounted
name = string # SMB file share name
storage_account = string # Storage account hosting the share
access_key = optional(string, null) # Fallback key; var.storage_access_keys wins when set
quota_gb = number # Share quota in GB
})), {})
replica_timeout = number # Execution timeout in seconds
trigger_schedule = optional(string, null) # Cron expression; null creates a manual-trigger-only job
user_managed_storage_app = optional(object({
id = string # App's user assigned identity ID
client_id = string # Emitted as AZURE_CLIENT_ID
}), null)
user_managed_queue_app = optional(object({
id = string # Queue publisher identity resource ID, attached alongside the storage identity
client_id = string # Its client ID; not emitted as an env var
}), null)
otel_exporter = optional(string, null) # OTLP endpoint; non-null adds collector env vars
workload_profile = optional(string, null) # Environment workload profile name; falls back to var.workload_profile
env_vars = optional(map(string), {}) # plain env var name -> value
key_vault_secrets = optional(map(string), {}) # env var name -> Key Vault secret name
tags = map(string) # Job-specific tags
}
Queue Writer Configuration
app_config.queue_writer is optional. When set, it is rendered as the queueWriter section of the Kastoria configuration document shared by every app and job, which is how the event notification job learns where to publish. It carries an account name and a managed identity client ID only — never a connection string. That client ID is the queue module's publisher identity, which the publishing job must carry through user_managed_queue_app. AZURE_CLIENT_ID stays owned by user_managed_storage_app, so a job carrying both identities uses the storage identity for storage and the publisher for the queue. See Wiring the Queue Writer for the expected shape.
Environment Variables and Key Vault-Referenced Secrets
Apps and jobs can receive secret environment variables referenced from the environment's
Azure Key Vault instead of literal values. Each app/job config accepts an optional
key_vault_secrets map — a sibling of env_vars — mapping env var name → Key Vault
secret name:
apps = {
kastoria = {
env_vars = {
DICOMWEB_URL = "https://..."
}
key_vault_secrets = {
MY_SECRET_VAR = "my-secret-var" # env var name -> KV secret name
}
}
}
jobs = {
stdyproc = {
key_vault_secrets = {
JOB_SECRET_VAR = "job-secret-var"
}
}
}
Requirements:
- key_vault_uri and key_vault_reader_identity_id must be set on the module when any
app or job uses key_vault_secrets (enforced by a lifecycle precondition).
- The reader identity must hold Key Vault Secrets User on the vault. It is attached
only to apps/jobs that actually declare key_vault_secrets, so other apps see no
identity drift.
- The secret must already exist in the vault before the app revision or job is created
(set it out-of-band with az keyvault secret set).
The secret is referenced with a versionless URI (${key_vault_uri}/secrets/<name>),
so the secret value never enters OpenTofu state, plan output, or PR comments. Rotation
behavior differs between apps and jobs:
- Apps: ACA detects a new secret version within ~30 minutes and automatically restarts active revisions referencing it. Scaled-to-zero apps get the latest value on cold start. OpenTofu shows no diff for KV-side rotation (benign). Pin a versioned URI in the env config if change control is required.
- Jobs: no long-running revisions — each execution resolves the versionless URI at start, so every run picks up the latest value immediately.
Valid CPU and Memory Combinations
| CPU (cores) | Memory Options |
|---|---|
| 0.25 | 0.5Gi |
| 0.5 | 1.0Gi |
| 0.75 | 1.5Gi |
| 1.0 | 2.0Gi |
| 1.25 | 2.5Gi |
| 1.5 | 3.0Gi |
| 1.75 | 3.5Gi |
| 2.0 | 4.0Gi |
Resources Created
azurerm_user_assigned_identity- Shared managed identity for ACR pull authenticationazurerm_log_analytics_workspace- Log workspace backing the Container App Environmentazurerm_container_app_environment- Container App Environment with VNET integrationazurerm_container_app_environment_storage- One per unique SMB share referenced byshare_mountsazurerm_container_app- One per entry inappsazurerm_container_app_job- One per entry injobsazurerm_storage_account/azurerm_storage_container- Blob container holding container app auth tokensazuread_application/azuread_service_principal/azuread_application_password- One per app declaringauthenticationazapi_resource- Container app auth config, one per app declaringauthentication
The AcrPull role assignment is not created by this module — see ACR Integration.
Network Requirements
The subnet provided via subnet_id must meet these requirements:
- Minimum CIDR:
/27(32 IPs) for Consumption, larger for Dedicated profiles - Delegated to
Microsoft.App/environments - No NSG rules blocking required ports (unless using custom NSG configuration)
Example subnet configuration:
resource "azurerm_subnet" "aca_subnet" {
name = "snet-container-apps"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = ["10.0.1.0/27"]
delegation {
name = "aca-delegation"
service_delegation {
name = "Microsoft.App/environments"
actions = [
"Microsoft.Network/virtualNetworks/subnets/join/action",
]
}
}
}
ACR Integration
The module creates a User-Assigned Managed Identity (identity_id / identity_principal_id outputs) which every container app and job uses to authenticate image pulls from acr_login_server.
The module does not grant the AcrPull role — assign it in the environment so the scope stays under the environment's provider configuration:
resource "azurerm_role_assignment" "acr_pull" {
scope = data.azurerm_container_registry.acr.id
role_definition_name = "AcrPull"
principal_id = module.compute_apps.identity_principal_id
}
Revision Modes
- Single: Only one revision is active at a time. New deployments replace the previous revision.
- Multiple: Multiple revisions can be active simultaneously, useful for blue/green deployments or traffic splitting.
Tagging Strategy
Tags are merged with the following priority:
1. Per-app tags (from apps[*].tags)
2. Global tags (from tags variable)
Per-app tags override global tags with the same key.
Security Considerations
- Uses Managed Identity for ACR authentication (no credentials stored)
- Uses (a separate) Managed Identity for Key Vault read authentication (no credentials stored) where desired
- Supports VNET integration for network isolation
- Each app can have internal-only ingress (set
external_enabled = false) - Consider using Azure Key Vault references for sensitive environment variables
Limitations
- All apps share the same Container App Environment and Managed Identity
- Single container per app (no sidecar support yet)
- No scaling rules (min/max replicas only)
- No health probe configuration
Available versions
v0.9.1
Azure Gateway Module
This OpenTofu module provisions the reverse proxy entrypoint for a Kastoria environment into an existing Azure Container App Environment. It routes public traffic to the environment's container apps over their internal FQDNs, and can optionally be fronted by a Cloudflare Tunnel and/or be paired with an mTLS proxy for client-certificate-authenticated callers.
Features
- Single entrypoint: A reverse proxy container app with a public or internal ingress on port 8080, scaling 1–5 replicas at 0.25 CPU / 0.5Gi.
- Declarative routing: Each
upstream_servicesentry becomes an{NAME}_ADDRESSenv var resolving to the service's internal FQDN, so no proxy config file is managed here. - Cloudflare Tunnel (optional): Provisions the tunnel, its ingress rules, proxied CNAME records, and a
cloudflaredconnector container app — removing the need for a public proxy ingress. - mTLS proxy (optional): A second proxy app with a platform-enforced client certificate mode and an optional IPv4 allowlist on its ingress.
- Passwordless everything: Images are pulled with a managed identity, and Key Vault secrets reach the containers as identity-backed
secretblocks rather than as configuration values. - Nothing sensitive in the repo: The mTLS caller CA and its password, and the tunnel token, are all read from or written to Key Vault.
- Independent toggles:
proxy_enabled,cloudflared_enabled, andmtls_ca/mtls_proxy_imageeach gate their own resources, so a tunnel-less or proxy-less deployment leaves nothing behind in state. - FinOps Ready:
app-prefixed naming and asubmodule = "Gateway"tag on every Azure resource (Cloudflare resources carry no tags).
Requirements
| Name | Version |
|---|---|
| azuread | ~> 3.0 |
| azurerm | ~> 4.0 |
| cloudflare | ~> 5.0 |
Providers
| Name | Version |
|---|---|
| azurerm | 4.76.0 |
| cloudflare | 5.19.1 |
Modules
No modules.
Resources
| Name | Type |
|---|---|
| azurerm_container_app.cloudflared | resource |
| azurerm_container_app.kastoria_mtls_proxy | resource |
| azurerm_container_app.kastoria_proxy | resource |
| azurerm_container_app_environment_certificate.mtls_ca | resource |
| azurerm_key_vault_secret.cf_tunnel_token | resource |
| cloudflare_dns_record.cf_cstore_tunnel | resource |
| cloudflare_dns_record.cf_tunnel | resource |
| cloudflare_zero_trust_tunnel_cloudflared.cf | resource |
| cloudflare_zero_trust_tunnel_cloudflared_config.cf | resource |
| azurerm_key_vault_secret.mtls_ca_certificate | data source |
| azurerm_key_vault_secret.mtls_ca_password | data source |
| cloudflare_zero_trust_tunnel_cloudflared_token.cf | data source |
| cloudflare_zone.this | data source |
Inputs
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| acr_login_server | The ACR login server for container image pulls | string |
n/a | yes |
| base_name [d] | Project name prefix for resource naming | string |
n/a | yes |
| cloudflare_account_id [d] | Cloudflare Account ID | string |
null |
no |
| cloudflare_zone_id [d] | Cloudflare Zone ID for the tunnel DNS record | string |
null |
no |
| cloudflared_enabled | Whether to deploy the cloudflared tunnel sidecar container | bool |
false |
no |
| cloudflared_image | Container image for cloudflared (e.g. cloudflare/cloudflared:2026.5.2) | string |
n/a | yes |
| cloudflared_public_hostname | The public hostname that Cloudflare will route to this tunnel | string |
null |
no |
| container_app_environment_id [d] | The container app environment ID to deploy the proxy into | string |
n/a | yes |
| cstore_internal_ingress | Do we need ingress for cstore-scu | bool |
false |
no |
| domain | The published container app domain | string |
n/a | yes |
| kastoria_reportview_enabled | Whether the perftest reporter is enabled | bool |
false |
no |
| kastoria_smartehr_enabled | Whether the smartehr upstream is enabled | bool |
true |
no |
| key_vault_id [d] | Resource ID of the Azure Key Vault (passed from parent module instead of data source lookup) | string |
null |
no |
| key_vault_name | Name of the Azure Key Vault | string |
null |
no |
| key_vault_resource_group_name | Resource group of the Key Vault (defaults to var.resource_group_name) | string |
null |
no |
| key_vault_secrets | Map of env var names to Key Vault secret names to inject into the proxy container | map(string) |
{} |
no |
| mtls_ca [d] | Optional mTLS CA certificate to register with the Container App Environment. Both the PFX certificate blob (base64, CA chain only, no private key) and its password are read from the Key Vault referenced by var.key_vault_id, so nothing certificate-related is kept in the repo. Requires var.key_vault_id. |
object({ |
null |
no |
| mtls_proxy_allowed_ip_ranges | IPv4 CIDR ranges allowed to reach the mTLS proxy ingress. Presence of any entry puts the ingress into deny-by-default: every address outside these ranges is rejected with "RBAC: Access Denied" before the container sees the request. An empty list means no IP filtering at all. Single addresses must be written as an explicit /32; IPv6 is not supported by Azure. |
list(string) |
[] |
no |
| mtls_proxy_client_certificate_mode | ACA platform-enforced client certificate mode for the mTLS proxy ingress: require (cert mandatory), accept (optional, forwarded in X-Forwarded-Client-Cert), ignore (dropped) | string |
"require" |
no |
| mtls_proxy_external_enabled | Whether the mTLS proxy publishes a public (external) ingress. When false the app is only reachable through the environment-internal FQDN, which is what same-environment callers use, so flipping this does not affect them. Combining this with an empty var.mtls_proxy_allowed_ip_ranges publishes the app to the whole internet protected only by its client certificate. |
bool |
false |
no |
| mtls_proxy_image | The image name for the mtls proxy if used | string |
null |
no |
| proxy_enabled | Whether to deploy the kastoria reverse proxy container app | bool |
true |
no |
| proxy_env_extra | Additional environment variables to pass to the proxy container | map(string) |
{} |
no |
| proxy_external_enabled | Whether the reverse proxy has external-facing ingress. Set false when using Cloudflare Tunnel. | bool |
true |
no |
| proxy_image | The container image for the reverse proxy | string |
n/a | yes |
| registry_identity_id | Resource ID of the user-assigned managed identity used for ACR authentication | string |
n/a | yes |
| resource_group_name [d] | The resource group the container app environment is in | string |
n/a | yes |
| tags | Tags to apply | map(string) |
{} |
no |
| upstream_services | Map of upstream services. Each entry produces a {NAME}_ADDRESS env var. | map(object({ |
{} |
no |
| user_assigned_identity_id | Resource ID of the user-assigned managed identity attached to the container app for Key Vault access | string |
null |
no |
| workload_profile_name | Workload profile name assigned to the gateway container apps | string |
"Consumption" |
no |
[d] Destructive: changing this input forces one or more resources to be destroyed and recreated (an OpenTofu/Terraform replacement) rather than updated in place.
Outputs
| Name | Description |
|---|---|
| cloudflare_tunnel_id | The ID of the Cloudflare tunnel |
| cloudflare_tunnel_record_name | The CNAME record name for the tunnel |
| cloudflare_tunnel_token | The tunnel token stored in Key Vault (sensitive) |
| cloudflared_container_app_id | The ID of the cloudflared tunnel container app |
| cloudflared_container_app_name | The name of the cloudflared tunnel container app |
| container_app_fqdn | The FQDN of the kastoria proxy container app (latest revision) |
| container_app_id | The ID of the kastoria proxy container app |
| container_app_name | The name of the kastoria proxy container app |
| mtls_proxy_app_fqdn | The FQDN of the kastoria mtls proxy container app (latest revision) |
| mtls_proxy_app_id | The ID of the kastoria mtls proxy container app |
| mtls_proxy_app_name | The name of the kastoria mtls proxy container app |
Example Usage
Reverse proxy only
module "gateway" {
source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/gateway?tag=<module-version>"
base_name = "merk-test"
resource_group_name = "merk-test-rg"
container_app_environment_id = module.compute_apps.container_app_environment_id
domain = "victoriousflower-bd8a4f3a.centralus.azurecontainerapps.io"
cloudflared_image = "not-used"
proxy_image = "myacr.azurecr.io/kastoria-proxy:latest"
acr_login_server = "myacr.azurecr.io"
registry_identity_id = module.compute_apps.identity_id
# Each entry becomes an {NAME}_ADDRESS env var pointing at
# app-{base_name}-{address}.internal.{domain}
upstream_services = {
kastoria_health = { address = "kastoria" }
kastoria_smartlaunch = { address = "smartlaunchapi" }
kastoria_consoleapi = { address = "consapi" }
kastoria_consoleui = { address = "consui" }
ohif_viewer = { address = "ohif" }
}
proxy_env_extra = {
LOG_LEVEL = "warn"
}
tags = {
owner = "merkalis"
env = "test"
envtype = "DevTest"
}
}
With a Cloudflare Tunnel
Requires the cloudflare provider credentials (CLOUDFLARE_API_TOKEN) and a Key Vault to
hold the generated tunnel token. Pair with proxy_external_enabled = false so the proxy is
reachable only through the tunnel.
module "gateway" {
# ... base_name, domain, upstream_services as above
proxy_external_enabled = false
cloudflared_enabled = true
cloudflared_image = "cloudflare/cloudflared:2026.5.2"
cloudflared_public_hostname = "app.merkal.io"
cloudflare_account_id = "abc123"
cloudflare_zone_id = "def456"
cstore_internal_ingress = true # also expose app-{base_name}-storescu
key_vault_name = module.secrets.vault_name
key_vault_id = module.secrets.vault_id
user_assigned_identity_id = module.secrets.identity_id
key_vault_secrets = {
PROXY_API_TOKEN = "proxy-api-token" # env var name -> KV secret name
}
}
With an mTLS proxy
Registers a caller CA with the Container App Environment and deploys a second proxy that
demands a client certificate. Both the certificate and its password are read from Key
Vault, so key_vault_id is mandatory here.
module "gateway" {
# ... base_name, domain, upstream_services as above
cloudflared_image = "not-used"
key_vault_name = module.secrets.vault_name
key_vault_id = module.secrets.vault_id
user_assigned_identity_id = module.secrets.identity_id
mtls_ca = {
name = "gateway-mtls-ca" # defaults shown
certificate_name = "callerCACert"
password_secret = "callerCACertPassword"
}
mtls_proxy_image = "myacr.azurecr.io/kastoria-mtls-proxy:latest"
mtls_proxy_client_certificate_mode = "require"
mtls_proxy_external_enabled = false
mtls_proxy_allowed_ip_ranges = ["203.0.113.0/24"]
}
Resources Created
| Resource | Condition | Notes |
|---|---|---|
azurerm_container_app.kastoria_proxy |
proxy_enabled |
app-{base_name}-kastoria-proxy, 0.25 CPU / 0.5Gi, 1–5 replicas, HTTP ingress on 8080 |
azurerm_container_app_environment_certificate.mtls_ca |
mtls_ca != null |
Caller CA registered with the ACA environment |
azurerm_container_app.kastoria_mtls_proxy |
mtls_ca != null && mtls_proxy_image != null |
app-{base_name}-mtls-proxy, 0.25 CPU / 0.5Gi, 1–5 replicas, mTLS ingress on 8080 |
cloudflare_zero_trust_tunnel_cloudflared.cf |
cloudflared_enabled |
Tunnel named app-{base_name}-cloudflared |
cloudflare_zero_trust_tunnel_cloudflared_config.cf |
cloudflared_enabled |
Ingress rules to the proxy (plus storescu when cstore_internal_ingress) |
cloudflare_dns_record.cf_tunnel |
cloudflared_enabled |
Proxied CNAME for cloudflared_public_hostname |
cloudflare_dns_record.cf_cstore_tunnel |
cloudflared_enabled |
Proxied CNAME cstore.{hostname}; the matching tunnel route only exists when cstore_internal_ingress |
azurerm_key_vault_secret.cf_tunnel_token |
cloudflared_enabled |
cloudflare-tunnel-token written to key_vault_id |
azurerm_container_app.cloudflared |
cloudflared_enabled |
app-{base_name}-cloudflared, 0.25 CPU / 0.5Gi, single replica |
The Cloudflare tunnel token is stored in Azure Key Vault rather than kept in a Terraform
variable, and the connector app reads it back through a managed-identity secret block.
This keeps the token out of configuration the container while OpenTofu still owns the
Key Vault secret itself.
Notes
Upstream Routing
upstream_services is the only routing input: each key becomes an upper-cased
{NAME}_ADDRESS env var resolving to the service's internal FQDN, which the proxy image
reads to build its routes. Toggling kastoria_reportview_enabled and
kastoria_smartehr_enabled emits KASTORIA_*_ENABLED flags for feature gates inside the
proxy image — they do not add or remove routes.
Cloudflare Tunnel
1. When
cloudflared_enabled = true, the module creates the tunnel through thecloudflareprovider, so that provider needsCLOUDFLARE_API_TOKENin the environment running the plan (CI/CD secret or local shell).cloudflare_account_idandcloudflare_zone_idare required in this mode, andcloudflared_public_hostnamemust be the full FQDN inside that zone (e.g.app.example.com) — the zone name is trimmed off it to derive the CNAME record name.2. The generated
cloudflare-tunnel-tokenlands in thekey_vault_idKey Vault with a key name oftunnel-tokenand the connector app consumes it as a managed-identity-backedsecret. That meanskey_vault_name(for the secret URI) anduser_assigned_identity_id(holdingKey Vault Secrets User) are both required whenever the tunnel is active.3. Set
proxy_external_enabled = falseto lock the proxy to internal-only when the tunnel is active; the tunnel reaches it over the environment-internal FQDN either way.
mTLS Proxy
1.
azurerm_container_app.kastoria_mtls_proxyis deployed only when bothmtls_caandmtls_proxy_imageare set. The ACA platform's (Envoy) ingress terminates TLS and enforcesmtls_proxy_client_certificate_mode; certificate chain validation against the caller CA happens inside the proxy image using theX-Forwarded-Client-Certheader, not in Envoy.2. The proxy always reads
kastoriaMTLSClientRolesfrom the Key Vault intoKASTORIA_MTLS_CLIENT_ROLES, so that secret must exist before the first apply. Building its secret URI needskey_vault_name, and reading it needsuser_assigned_identity_idwithKey Vault Secrets Useron the vault — the same pairing the Cloudflare tunnel connector relies on.3.
mtls_proxy_allowed_ip_rangesflips the ingress to deny-by-default as soon as it contains anything — an empty list means no IP filtering at all. Combined withmtls_proxy_external_enabled = trueand an empty list, the app is reachable from the whole internet guarded only by its client certificate. Entries are validated at plan time as IPv4 CIDRs (/32for a single address);0.0.0.0/0is rejected because it would silently disable the restriction. Rule names derive from the CIDR itself, so removing one range never renames its siblings.
Available versions
v0.9.1
Azure Telemetry Module
This OpenTofu module provisions an OpenTelemetry collector as a container app inside an existing Container App Environment. When Grafana Cloud is enabled it also provisions the Azure Monitor data source and the subscription-scoped role assignment, and points the collector's OTLP exporter at the stack.
Features
- OpenTelemetry collector container app deployed into an existing ACA environment (internal gRPC/HTTP OTLP ingress).
- Grafana Cloud logs, metrics, and traces pipelines via an access policy token — gated by
enable_grafana. - Grafana Azure Monitor data source provisioned through the Grafana provider.
- Optional subscription-scoped Monitoring Reader role assignment for the Grafana service principal (
create_subscription_role_assignment). - Grafana-optional: with
enable_grafana = falsethe collector still deploys (exporting todebug) and no Grafana credentials or provider are required — everygrafana_*andservice_principal_*input becomes optional. - Fail-fast: when
enable_grafana = true, a missing credential aborts the plan up front via aterraform_dataprecondition rather than surfacing as a provider 401 during refresh. - Configurable workload profile and collector image for FIPS builds.
Requirements
| Name | Version |
|---|---|
| azapi | 2.7.0 |
| azuread | ~> 3.0 |
| azurerm | ~> 4.0 |
| grafana | ~> 4.0 |
Providers
| Name | Version |
|---|---|
| azurerm | 4.81.0 |
| grafana | 4.45.2 |
| terraform | n/a |
Modules
No modules.
Resources
| Name | Type |
|---|---|
| azurerm_container_app.otel_gateway | resource |
| azurerm_role_assignment.grafana_monitoring_reader | resource |
| grafana_data_source.azure_monitor | resource |
| terraform_data.grafana_credentials_check | resource |
| azurerm_subscription.current | data source |
| grafana_cloud_stack.this | data source |
Inputs
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| acr_login_server | The login server URL of the ACR (e.g., myacr.azurecr.io) for pulling the OTel collector image | string |
n/a | yes |
| container_app_environment_id [d] | The container app environment id to install the collector in | string |
n/a | yes |
| create_subscription_role_assignment | Whether or not to create the grafana role assignment for this subscription | bool |
true |
no |
| enable_grafana | Whether to wire this environment to Grafana Cloud. When false the module deploys only the OTel collector container app and reads no Grafana credentials, so this subscription's telemetry stays in-environment. When true (the default) every grafana_ and service_principal_ input is required and the grafana provider must be configured by the caller. |
bool |
true |
no |
| grafana_cloud_stack_slug | Our grafana cloud stack name. Required when var.enable_grafana is true. | string |
null |
no |
| grafana_cloud_token | Grafana Cloud Access Policy Token (must have logs:write, metrics:write, traces:write). Required when var.enable_grafana is true. | string |
null |
no |
| grafana_data_source_name | The name of the grafana data source | string |
"Azure Monitor" |
no |
| grafana_instance_id | Grafana Cloud Instance ID. Required when var.enable_grafana is true. | string |
null |
no |
| otel_collector_image | The container image for the OpenTelemetry collector (defaults to the upstream contrib image) | string |
"acrmerkalis2b65.azurecr.io/minimus/opentelemetry-collector-contrib-fips:0.156.0" |
no |
| registry_identity_id | Resource ID of the user-assigned managed identity used for ACR authentication | string |
n/a | yes |
| resource_group_name [d] | The name of the resource group the container app env is in | string |
n/a | yes |
| service_principal_client_id | The grafana service principal's client id. Required when var.enable_grafana is true. | string |
null |
no |
| service_principal_client_secret | The grafana service principal's client secret. Required when var.enable_grafana is true. | string |
null |
no |
| service_principal_id [d] | The grafana service principal's id. Required when var.enable_grafana is true. | string |
null |
no |
| subscription_id [d] | The azure subscription id | string |
n/a | yes |
| tenant_id | The grafana service principal's tenant id. Required when var.enable_grafana is true. | string |
null |
no |
| workload_profile_name | Workload profile name assigned to the OTel gateway container app | string |
"Consumption" |
no |
[d] Destructive: changing this input forces one or more resources to be destroyed and recreated (an OpenTofu/Terraform replacement) rather than updated in place.
Outputs
| Name | Description |
|---|---|
| grafana_datasource_uid | UID of the Grafana Azure Monitor data source (null when var.enable_grafana is false) |
Example Usage
With Grafana Cloud (default)
module "telemetry" {
source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/telemetry?tag=<module-version>"
resource_group_name = "rg-prod-observability"
container_app_environment_id = module.compute.container_app_environment_id
subscription_id = data.azurerm_client_config.current.subscription_id
tenant_id = var.grafana_tenant_id
acr_login_server = "myacr.azurecr.io"
registry_identity_id = module.compute.identity_id
otel_collector_image = "<hardened-opentelemetry-collector-image>"
grafana_cloud_stack_slug = "my-stack"
grafana_instance_id = "123456"
grafana_cloud_token = "<access-policy-token>"
service_principal_id = "<sp-object-id>"
service_principal_client_id = "<sp-client-id>"
service_principal_client_secret = "<sp-secret>"
}
Without Grafana credentials
Deploys only the collector; it exports to debug and never touches the Grafana
provider. Set enable_grafana = false and omit every grafana_* and
service_principal_* input:
module "telemetry" {
source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/telemetry?tag=<module-version>"
resource_group_name = "rg-dev-observability"
container_app_environment_id = module.compute.container_app_environment_id
subscription_id = data.azurerm_client_config.current.subscription_id
acr_login_server = "myacr.azurecr.io"
registry_identity_id = module.compute.identity_id
otel_collector_image = "<hardened-opentelemetry-collector-contrib-image>"
enable_grafana = false
}
Notes
1.
grafana_cloud_tokenandservice_principal_client_secretare sensitive and must never be committed to version control.2.
<hardened-opentelemetry-collector-contrib-image>can be pulled from various specialized providers and stored in your local Azure Container Registry.
Available versions
v0.9.1
Sample Deployment
Sample Deployment
A reference OpenTofu configuration that composes the Merkalis Azure modules by
hand — networking, secrets, objectstore, queue, compute, gateway,
and telemetry wired together directly, so that every connection between modules
is visible. It provisions the same stack shown in the
reference deployment diagram.
The configuration is consumed from the Distribution Registry as signed OCI module packages, which is the same route every external consumer uses.
| Page | Contents |
|---|---|
| Configuration | The full configuration files: main.tf, variables.tf, and the per-environment values |
| Walkthrough | Module sources, build order, the wiring you own, prerequisites, and gotchas |
For the per-edge output→input tables, see Module Dependencies.
What it covers
Object storage shards, the shared storage-accessor identity, Key Vault secret
injection into container apps, CORS between two apps, internal-only ingress,
cron-triggered container jobs, the event notification queue
and its queue_writer wiring, and the mTLS gateway proxy with
enable_grafana = false telemetry.
What it omits on purpose
The reverse proxy container app and the Cloudflare Tunnel
(proxy_enabled = false, cloudflared_enabled = false), file store and share
mounts (see the appendix in
Walkthrough),
the optional database module, workload profiles other than the Consumption
default, and Grafana telemetry wiring.
Intentionally not applied as-is
This is a teaching configuration: the state backend block is commented out so the configuration validates without provisioning state storage, and it is not meant to be applied directly. Adapt the naming, backend, and environment values to your own subscription before planning.
Validation
docker login acrmerkalisdist0c66.azurecr.io # OpenTofu reads ~/.docker/config.json
tofu init -backend=false
tofu fmt -check
tofu validate
tofu plan additionally needs a real subscription, an ACR containing the
referenced application images (kastoria-ohif, kastoria-health,
kastoria-smart-launch-api, kastoria-consoleapi, kastoria-consoleui,
kastoria-studyprocessor, kastoria-eventnotification, kastoria-proxy and
kastoria-proxy-mtls), and the
Key Vault secrets described in
Walkthrough.
Sample Deployment Configuration
The complete configuration files of the sample deployment. Everything
structural lives in main.tf; only five values change per environment. How
these pieces fit together is explained in
Walkthrough.
main.tf
# Reference composition: the component modules wired together by hand, with no
# orchestration module in front of them. This spells out the wiring so that it
# is visible. See walkthrough.md.
#
# The modules below are consumed from the dist registry as signed OCI module
# packages (see the "Module sources" section). That means `tofu init` here
# needs registry credentials and a published tag -- see the validation section
# of the sample deployment overview.
terraform {
# oci:// module sources are native from OpenTofu 1.10.
required_version = ">= 1.10"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
azuread = { source = "hashicorp/azuread", version = "~> 3.0" }
azapi = { source = "azure/azapi", version = "2.7.0" }
}
# A backend is required to plan or apply against real Azure. It is commented
# out so this directory can be validated without provisioning state storage:
# tofu init -backend=false && tofu validate
# backend "azurerm" {
# resource_group_name = "<state-resource-group>"
# storage_account_name = "<state-storage-account>"
# container_name = "tfstate"
# key = "environment/azure/sample/terraform.tfstate"
# subscription_id = "<state-subscription-id>"
# use_azuread_auth = true
# }
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
storage_use_azuread = true
}
# The cloudflare provider must be declarable (the gateway module pulls it
# into the graph) but is never configured here: the gateway's Cloudflare
# resources are all count-gated on var.cloudflared_enabled = false.
# ---------------------------------------------------------------------------
# Naming
# ---------------------------------------------------------------------------
# Every storage-account-backed module appends this suffix to resource names for
# global uniqueness. It is pinned here so the names below are predictable and
# this file is copy-pasteable. Changing it renames every storage account and
# the vault.
locals {
project_prefix = "abcd"
vnet_cidr = "10.9.0.0/16"
rg_tags = merge(var.rg_tags, { submodule = "resource-group" })
}
# ---------------------------------------------------------------------------
# Module sources
# ---------------------------------------------------------------------------
# Each component is consumed as a signed OCI module package from the dist
# registry: one OCI repository per component, published per release tag, so a
# single tag pins every component on a shared release train.
#
# No `//` subdirectory: the published package is re-rooted at the module root,
# so `modules/azure/compute` is the compute module itself.
#
# locals, not variables: `tofu init` resolves module sources before any
# variable value exists.
locals {
modules_base = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure"
modules_version = "v0.0.1"
}
resource "azurerm_resource_group" "env" {
name = "${var.project_name}-rg"
location = var.location
tags = local.rg_tags
}
# One user-assigned identity shared by every workload that reads object storage.
# Its client_id is what the Kastoria storage tier config calls
# managedIdentityClientId, so it must be threaded into app_config (see below).
resource "azurerm_user_assigned_identity" "app_identity" {
name = "id-app-storage-accessor"
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
}
# ---------------------------------------------------------------------------
# Networking
# ---------------------------------------------------------------------------
# Subnet names are a contract between this file and the modules that index
# created_subnets by key ("compute", "storage", "gateway").
locals {
subnets = {
compute = {
address_prefixes = [cidrsubnet(local.vnet_cidr, 5, 0)]
service_endpoints = ["Microsoft.ContainerRegistry", "Microsoft.KeyVault", "Microsoft.Storage"]
delegation_config = {
name = "container-app-delegation"
service_delegation = {
name = "Microsoft.App/environments"
actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
}
}
}
storage = {
address_prefixes = [cidrsubnet(local.vnet_cidr, 8, 8)]
service_endpoints = ["Microsoft.Storage"]
}
gateway = {
address_prefixes = [cidrsubnet(local.vnet_cidr, 8, 10)]
service_endpoints = []
}
}
}
module "networking" {
source = "${local.modules_base}/networking?tag=${local.modules_version}"
base_name = var.project_name
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
address_space = [local.vnet_cidr]
subnets = local.subnets
tags = local.rg_tags
}
# ---------------------------------------------------------------------------
# Secrets
# ---------------------------------------------------------------------------
# The purge protection, soft delete retention, and SKU below correspond to a
# "dev"-profile key vault; raise them for production environments.
module "secrets" {
source = "${local.modules_base}/secrets?tag=${local.modules_version}"
base_name = var.project_name
suffix = local.project_prefix
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
allowed_subnet_ids = [module.networking.created_subnets["compute"].id]
allowed_ips = var.allowed_ips
purge_protection_enabled = false
soft_delete_retention_days = 7
sku_name = "standard"
tags = local.rg_tags
}
# ---------------------------------------------------------------------------
# Object storage
# ---------------------------------------------------------------------------
# Outer key = one storage account; inner key = one blob container plus a table
# of the same name with '-' stripped.
locals {
object_shards = {
"sn01" = { "hot-nodes-01" = {} }
"sb01" = { "hot-blocks-01" = {} }
"sb02" = { "hot-blocks-02" = {} }
"sb03" = { "hot-blocks-03" = {} }
}
# The table mirrors the container with '-' stripped so app_config can name
# the tables the module will create.
storage_table_names = {
for shard_key, containers in local.object_shards :
shard_key => [for container in keys(containers) : replace(container, "-", "")]
}
# storage_accounts is marked sensitive (it carries access keys); the storage
# tier config needs only the names, so unwrap it before it reaches a
# non-sensitive expression.
object_account_names = {
for shard_key, sa in nonsensitive(module.object_store.storage_accounts) :
shard_key => sa.name
}
}
module "object_store" {
source = "${local.modules_base}/objectstore?tag=${local.modules_version}"
base_name = var.project_name
suffix = local.project_prefix
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
allowed_subnet_ids = [
module.networking.created_subnets["storage"].id,
module.networking.created_subnets["compute"].id
]
shards = local.object_shards
user_managed_identity_principal_id = azurerm_user_assigned_identity.app_identity.principal_id
allowed_ips = var.allowed_ips
tags = local.rg_tags
}
# ---------------------------------------------------------------------------
# Event notification queue
# ---------------------------------------------------------------------------
# A dedicated, keyless storage account holding the queue evtnotify publishes
# to. The module creates its own publisher identity (umi-<base_name>-queue-publisher)
# and grants it send; the storage accessor identity has no queue access. Each
# queue_reader_principal_ids entry is granted Storage Queue Data Message Processor.
module "queue" {
source = "${local.modules_base}/queue?tag=${local.modules_version}"
base_name = var.project_name
suffix = local.project_prefix
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
# Only the compute subnet publishes; nothing else in the VNet reads this queue.
allowed_subnet_ids = [
module.networking.created_subnets["compute"].id
]
reader_principal_ids = var.queue_reader_principal_ids
# Operator ranges plus the reader's egress. Seeds the initial rule set only:
# the module ignores later ip_rules changes.
allowed_ips = concat(var.allowed_ips, var.queue_reader_ips)
tags = local.rg_tags
}
# ---------------------------------------------------------------------------
# Compute
# ---------------------------------------------------------------------------
# apps and jobs each carry a flat `tags` map, and merge() is repeated per entry
# because the two maps are indexed by different keys.
locals {
# Apps and jobs that talk to object storage get the shared accessor identity.
storage_app_names = ["kastoria", "smartlaunchapi"]
storage_job_names = ["stdyproc", "evtnotify"]
# Jobs that publish Notification Events get the queue publisher identity.
# Independent of storage_job_names: evtnotify is in both, because it reads the
# Changelog from object storage, so it carries both identities.
queue_job_names = ["evtnotify"]
storage_identity = {
id = azurerm_user_assigned_identity.app_identity.id
client_id = azurerm_user_assigned_identity.app_identity.client_id
}
queue_publisher = {
id = module.queue.publisher_id
client_id = module.queue.publisher_client_id
}
apps = {
ohif = {
container_config = {
name = "ohif"
image = "${var.acr_login_server}/kastoria-ohif:stable"
cpu = 0.5
memory = "1Gi"
min_replicas = 0
}
ingress_config = {
external_enabled = true
target_port = 8080
}
env_vars = {
DICOMWEB_URL = "https://sample.example.org"
}
revision_mode = "Single"
otel_exporter = "http://otel-gateway:4317"
tags = merge(local.rg_tags, {
app = "OHIF"
submodule = "compute"
})
}
# The Kastoria API. Reads its JWT verifying key from the environment's vault
# under a different name than the env var that receives it, and allows the
# OHIF origin through CORS.
kastoria = {
container_config = {
name = "kastoria-health"
image = "${var.acr_login_server}/kastoria-health:stable"
cpu = 2
memory = "4Gi"
min_replicas = 0
}
ingress_config = {
external_enabled = true
target_port = 3000
transport = "http"
cors_app_name = "ohif"
}
revision_mode = "Single"
key_vault_secrets = {
NO_JWT_VERIFYING_KEY = "jwtVerifyingKey"
}
otel_exporter = "http://otel-gateway:4317"
tags = merge(local.rg_tags, {
app = "KASTORIA-HEALTH"
submodule = "compute"
})
}
smartlaunchapi = {
container_config = {
name = "kastoria-smart-launch-api"
image = "${var.acr_login_server}/kastoria-smart-launch-api:stable"
cpu = 0.5
memory = "1Gi"
min_replicas = 0
}
ingress_config = {
external_enabled = true
target_port = 4000
transport = "http"
}
revision_mode = "Single"
key_vault_secrets = {
NO_JWT_SIGNING_KEY = "jwtSigningKey"
}
otel_exporter = "http://otel-gateway:4317"
env_vars = {
OHIF_VIEWER_URL = "https://sample.example.org"
REDIRECT_HOST = "https://sample.example.org"
}
tags = merge(local.rg_tags, {
app = "KASTORIA-HEALTH"
submodule = "compute"
})
}
# internal-only ingress: reachable from inside the environment, not from the
# internet.
consapi = {
container_config = {
name = "kastoria-consapi"
image = "${var.acr_login_server}/kastoria-consoleapi:stable"
cpu = 2
memory = "4Gi"
min_replicas = 0
}
ingress_config = {
external_enabled = false
target_port = 3005
transport = "http"
}
revision_mode = "Single"
otel_exporter = "http://otel-gateway:4317"
env_vars = {
OHIF_VIEWER_URL = "https://sample.example.org"
}
tags = merge(local.rg_tags, {
app = "KASTORIA-CONSAPI"
submodule = "compute"
})
}
consui = {
container_config = {
name = "kastoria-consui"
image = "${var.acr_login_server}/kastoria-consoleui:stable"
cpu = 0.5
memory = "1Gi"
min_replicas = 0
}
ingress_config = {
external_enabled = true
target_port = 8081
transport = "http"
}
env_vars = {
VITE_BASE_PATH = "/console/"
}
revision_mode = "Single"
tags = merge(local.rg_tags, {
app = "KASTORIA-CONSUI"
submodule = "compute"
})
}
}
jobs = {
stdyproc = {
container_config = {
name = "kastoria-studyprocessor"
image = "${var.acr_login_server}/kastoria-studyprocessor:stable"
cpu = 2
memory = "4Gi"
args = ["process", "DICOMStudy"]
}
otel_exporter = "http://otel-gateway:4317"
replica_timeout = 3600
trigger_schedule = "*/2 * * * *"
# Derived from replica_timeout minus a cold-start allowance.
env_vars = {
STUDY_PROCESSOR_RUN_BUDGET_MS = "3480000"
}
tags = merge(local.rg_tags, {
app = "STUDYPROCESSOR"
submodule = "compute"
})
}
# Same cadence and timeout as stdyproc. A schedule that fires more often
# than a Run can finish is safe: the Run Lease makes the overlapping
# Execution decline and exit 0. There is no run budget to derive; a Run
# ends when it has caught up with the Changelog.
evtnotify = {
container_config = {
name = "kastoria-eventnotification"
image = "${var.acr_login_server}/kastoria-eventnotification:stable"
cpu = 1
memory = "2Gi"
args = ["publish"]
}
otel_exporter = "http://otel-gateway:4317"
replica_timeout = 3600
trigger_schedule = "*/2 * * * *"
tags = merge(local.rg_tags, {
app = "EVENTNOTIFICATION"
submodule = "compute"
})
}
}
apps_final = {
for k, v in local.apps : k => merge(v,
contains(local.storage_app_names, k) ? { user_managed_storage_app = local.storage_identity } : {}
)
}
jobs_final = {
for k, v in local.jobs : k => merge(v,
contains(local.storage_job_names, k) ? { user_managed_storage_app = local.storage_identity } : {},
contains(local.queue_job_names, k) ? { user_managed_queue_app = local.queue_publisher } : {}
)
}
# The storage tier document the API is configured with. Account names come
# from the objectstore outputs, the table names follow the module's naming
# rule, and managedIdentityClientId is the accessor identity created above.
# Container and table names are author-chosen, so they stay literal.
storage_tiers = jsonencode([{
name = "hot"
blockStore = [{
type = "azure-hybrid"
configuration = {
shards = [
for shard_key in ["sb01", "sb02", "sb03"] : {
accountName = local.object_account_names[shard_key]
containerName = keys(local.object_shards[shard_key])[0]
tableName = local.storage_table_names[shard_key][0]
managedIdentityClientId = local.storage_identity.client_id
credentialType = "managed-identity"
endpoint = "https://${local.object_account_names[shard_key]}.blob.core.windows.net"
prefix = ""
}
]
}
}]
namedRoot = [{
type = "azure-hybrid"
configuration = {
accountName = local.object_account_names["sn01"]
containerName = keys(local.object_shards["sn01"])[0]
tableName = local.storage_table_names["sn01"][0]
managedIdentityClientId = local.storage_identity.client_id
credentialType = "managed-identity"
endpoint = "https://${local.object_account_names["sn01"]}.blob.core.windows.net"
prefix = ""
}
}]
changelog = {
type = "key-value"
}
}])
# Rendered as the queueWriter section of the configuration document. The
# identity is the queue module's publisher, which evtnotify carries through
# user_managed_queue_app; no connection string, because the account has no keys.
queue_writer = jsonencode({
type = "azure-storage-queue"
configuration = {
accountName = module.queue.storage_account_name
managedIdentityClientId = local.queue_publisher.client_id
queueName = module.queue.queue_name
messageEncoding = "text"
}
})
app_config = {
organization = "Example Org"
environment_name = "Sample"
purpose = "sample"
system_storage = jsonencode([])
storage_tiers = local.storage_tiers
queue_writer = local.queue_writer
}
}
module "compute" {
source = "${local.modules_base}/compute?tag=${local.modules_version}"
# Microsoft.App must be registered before the container app environment
# exists. On a fresh subscription, add an
# azurerm_resource_provider_registration resource and declare the depends_on.
base_name = var.project_name
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
tenant_id = data.azurerm_client_config.current.tenant_id
subnet_id = module.networking.created_subnets["compute"].id
gateway_subnet_id = module.networking.created_subnets["gateway"].id
acr_login_server = var.acr_login_server
apps = local.apps_final
jobs = local.jobs_final
# Leave at its default {} because no app or job declares share_mounts — see
# the file store appendix in walkthrough.md before re-enabling mounts.
storage_access_keys = {}
key_vault_uri = module.secrets.vault_uri
key_vault_reader_identity_id = module.secrets.identity_id
app_config = local.app_config
app_secrets = {}
tags = local.rg_tags
}
data "azurerm_client_config" "current" {}
# ---------------------------------------------------------------------------
# Gateway
# ---------------------------------------------------------------------------
module "gateway" {
source = "${local.modules_base}/gateway?tag=${local.modules_version}"
base_name = var.project_name
resource_group_name = azurerm_resource_group.env.name
container_app_environment_id = module.compute.container_app_environment_id
# Required even though every consumer of it is disabled: var.domain feeds the
# upstream address locals, which reach the mTLS proxy's environment. The real
# default domain is only known after the environment exists, so the gateway
# necessarily reads it from compute.
domain = module.compute.container_app_environment_default_domain
# Required input; unused because proxy_enabled = false.
proxy_image = "${var.acr_login_server}/kastoria-proxy:stable"
acr_login_server = var.acr_login_server
registry_identity_id = module.compute.identity_id
# The reverse proxy app is replaced here by the mTLS proxy below, which lives
# in the same module and is gated on mtls_ca independently of proxy_enabled.
proxy_enabled = false
# Produces {NAME}_ADDRESS env vars on the mTLS proxy. With proxy_enabled =
# false nothing in this deployment consumes them; they are kept to show the
# shape, and must be pruned if you re-enable the proxy with a different app
# set.
upstream_services = {
kastoria_health = { address = "kastoria" }
kastoria_smartlaunch = { address = "smartlaunchapi" }
kastoria_consoleapi = { address = "consapi" }
kastoria_consoleui = { address = "consui" }
ohif_viewer = { address = "ohif" }
}
# cloudflare/cloudflared:*
# Required at plan time regardless of cloudflared_enabled: the variable has no
# default and validate fails on "required variable not set" before any count
# is evaluated.
cloudflared_enabled = false
cloudflared_image = "cloudflare/cloudflared:2026.5.2"
# mTLS proxy: publishes an internet-facing ingress that accepts only clients
# presenting a certificate chained to the CA below. The CA's PFX blob and
# password, plus kastoriaMTLSClientRoles, must already exist in this vault.
mtls_ca = {
name = "gateway-mtls-ca"
certificate_name = "callerCACert"
password_secret = "callerCACertPassword"
}
mtls_proxy_image = "${var.acr_login_server}/kastoria-proxy-mtls:stable"
mtls_proxy_external_enabled = true
mtls_proxy_allowed_ip_ranges = ["203.0.113.0/24"]
key_vault_id = module.secrets.vault_id
key_vault_name = module.secrets.vault_name
# The identity the mTLS proxy uses to resolve its Key Vault-backed secret at
# runtime; it must be Secrets User on the vault.
user_assigned_identity_id = module.secrets.identity_id
tags = local.rg_tags
}
# ---------------------------------------------------------------------------
# Telemetry
# ---------------------------------------------------------------------------
# enable_grafana = false deploys the OTel collector and reads no Grafana
# credentials, so the grafana provider is neither configured nor required.
module "telemetry" {
source = "${local.modules_base}/telemetry?tag=${local.modules_version}"
container_app_environment_id = module.compute.container_app_environment_id
resource_group_name = azurerm_resource_group.env.name
subscription_id = var.subscription_id
acr_login_server = var.acr_login_server
registry_identity_id = module.compute.identity_id
enable_grafana = false
create_subscription_role_assignment = false
}
# ---------------------------------------------------------------------------
# Container registry access
# ---------------------------------------------------------------------------
# The ACR lives in this subscription, so a single provider suffices. When it
# lives elsewhere, add a provider alias and look the registry up with a data
# source:
#
# provider "azurerm" {
# alias = "infra"
# features {}
# subscription_id = "<registry-subscription-id>"
# }
#
# data "azurerm_container_registry" "acr" {
# provider = azurerm.infra
# name = "<registry-name>"
# resource_group_name = "<registry-resource-group>"
# }
#
# then scope the assignment below to data.azurerm_container_registry.acr.id.
resource "azurerm_role_assignment" "acr_pull" {
scope = var.acr_resource_id
role_definition_name = "AcrPull"
principal_id = module.compute.identity_principal_id
}
variables.tf
variable "project_name" {
description = "Name of the project used for resource naming"
type = string
}
variable "location" {
description = "Azure region to deploy into"
type = string
}
variable "subscription_id" {
description = "The environment's subscription id"
type = string
}
variable "acr_login_server" {
description = "The login server URL of the ACR holding our app containers (e.g. myacr.azurecr.io)"
type = string
}
variable "acr_resource_id" {
description = "Resource ID of the ACR that app images are pulled from; granted AcrPull on the container app environment identity"
type = string
}
variable "allowed_ips" {
description = "IP addresses to whitelist on the Key Vault and storage accounts"
type = list(string)
default = []
}
variable "queue_reader_principal_ids" {
description = "Entra principal IDs of the queue readers, granted Storage Queue Data Message Processor"
type = list(string)
default = []
}
variable "queue_reader_ips" {
description = "Egress ranges of queue readers outside the VNet; seeds the queue account's initial firewall rules only"
type = list(string)
default = []
}
variable "rg_tags" {
description = "Tags to apply to the resource group (propagated from there to every module)"
type = map(string)
default = {
owner = "example"
env = "sample"
envtype = "DevTest"
}
}
sample.auto.tfvars
Five values — plus the tags — change per environment; everything structural
lives in main.tf:
project_name = "sample"
location = "eastus2"
subscription_id = "00000000-0000-0000-0000-000000000000"
acr_login_server = "acrexample0000.azurecr.io"
acr_resource_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/example-infra-rg/providers/Microsoft.ContainerRegistry/registries/acrexample0000"
rg_tags = {
owner = "example"
env = "sample"
envtype = "DevTest"
}
project_name is the naming base for everything: sample-rg, appenv-sample,
app-sample-kastoria, job-sample-stdyproc, job-sample-evtnotify,
kv-sample-abcd, saqsampleabcd, umi-sample-queue-publisher.
Sample Deployment Walkthrough
The sample deployment configuration builds a complete
Kastoria stack from the individual components — networking, secrets,
objectstore, queue, compute, gateway, telemetry — composed directly, so that
every wire is visible. This page walks through what that composition involves:
where the module packages come from, the build order, the wiring you own
yourself, the prerequisites, and the gotchas.
For the per-edge output→input tables, see Module Dependencies.
Where the modules come from
The sample pulls every component from the Distribution Registry as a signed OCI module package:
locals {
modules_base = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure"
modules_version = "v0.0.1"
}
module "compute" {
source = "${local.modules_base}/compute?tag=${local.modules_version}"
# ...
}
Three consequences of this form:
- The tag is a git tag. All components publish together on one release
train, so a single
modules_versionpins the whole composition. There are no independent per-module versions to reconcile. - No
//subdirectory suffix. Each published package is one module, re-rooted at its module root. Writingmodules/azure/compute//azurefinds nothing. - The
azure/element is the cloud, not a path. Customer entitlement is granted overmodules/azure/*as a namespace.
Two things to know about the address itself, before tofu init ever runs:
Module source interpolation is resolved at init, so it has to be locals or
variables. Nothing in a source string may reference state or a provider,
but locals and variables do resolve — init evaluates them itself, and prompts
interactively (which in CI is a hang or a hard failure) if a variable has no
value available. Locals are used in the sample because nothing downstream
overrides them; a variable with a default works too
(?tag=${var.module_version}) and additionally picks up *.auto.tfvars and
-var at init, which is what you want if the pin should be overridable per
environment.
The point of interpolating at all is that changing the release for a whole composition stays a one-token edit instead of a search-and-replace across every module block.
The credential is a registry login, not an Azure login. OpenTofu reads
~/.docker/config.json, so docker login acrmerkalisdist0c66.azurecr.io with
your scoped token is the prerequisite; see
Registry — Authenticate.
az login does not help here, and CI needs the same credential written before
its tofu init step. Signature verification is not part of init either;
that is notation verify acrmerkalisdist0c66.azurecr.io/modules/azure/compute:v0.0.1,
run separately.
Tags are mutable, so where the supply chain matters pin
?digest=<sha256:...> instead of ?tag= — one or the other, never both in one
address.
Upgrading is a one-line change to modules_version plus tofu init -upgrade,
which is what re-downloads the package: a plain init reuses whatever is
already in .terraform/modules.
If you keep a local checkout of the module sources, you can point each source
at a relative path instead — "<path>/modules/compute/azure" — and every wire
described below is unchanged: only the addresses differ.
Published packages are as of their tag, which may predate the interfaces
described here and on the module pages. At the time of writing, compute
inputs key_vault_uri and key_vault_reader_identity_id, gateway inputs
proxy_enabled and the mtls_* group, telemetry's enable_grafana
flag, the queue module, and compute's app_config.queue_writer are newer
than the v0.0.1 tag. If tofu validate reports unsupported
arguments, check which release introduced them before treating the sample's
wiring as wrong.
The whole call site
Everything structural lives in
main.tf; only these five values change per
environment (sample.auto.tfvars):
project_name = "sample"
location = "eastus2"
subscription_id = "00000000-0000-0000-0000-000000000000"
acr_login_server = "acrexample0000.azurecr.io"
acr_resource_id = "/subscriptions/.../registries/acrexample0000"
project_name is the naming base for everything: sample-rg, appenv-sample,
umi-sample, app-sample-kastoria, job-sample-stdyproc,
job-sample-evtnotify, kv-sample-abcd, saqsampleabcd.
The module release pin is not among those five: it is not
environment-specific, so it sits in main.tf. Nor does it relate to
acr_login_server / acr_resource_id — those point at the ACR holding the
app containers, which the environment pulls at runtime with AcrPull; the
module registry is build-time only, and authenticates with a scoped token
rather than with Azure.
Build order
Terraform/OpenTofu derives order from references, so you do not declare it. Spelled out, the dependency levels are:
azurerm_resource_group.env+azurerm_user_assigned_identity.app_identitymodule.networking— needs the resource groupmodule.secrets,module.object_store,module.queue— need subnet IDs from networkingmodule.compute— needs subnets, the vault URI and reader identity, the accessor identity, the storage and queue account names, and the queue publisher identitymodule.gateway,module.telemetry— needcompute's container app environment ID
Two edges are easy to miss because they are the only reason a module exists in the graph:
gateway.domainreadscompute.container_app_environment_default_domain. The real domain is only known after the environment is created, so the gateway cannot be ordered before compute even when you would prefer to hardcode a domain and deploy them in parallel.app_config.storage_tiers, which iscomputeinput, readsobject_store.storage_accountsand the accessor identity'sclient_id. So storage accounts are an input to compute, not merely a thing apps reach at runtime.app_config.queue_writerreadsqueue.storage_account_name,queue.queue_nameandqueue.publisher_client_idthe same way, and theevtnotifyjob readsqueue.publisher_id, so the queue module must be applied before compute.
The wiring you perform
Four small transformations must happen between your environment values and the module inputs. Composed directly, they are yours to write — all four appear in the sample configuration.
1. The naming suffix
secrets, filestore and objectstore all take a required suffix. The
suffix can be generated with a random_id:
resource "random_id" "project_prefix" {
keepers = { project_name = var.project_name }
byte_length = 2
}
locals {
project_prefix_hex = var.project_prefix != null ? var.project_prefix : random_id.project_prefix.hex
}
The sample pins it instead:
locals {
project_prefix = "abcd"
}
Storage account names are then
substr("${replace(lower("sa${base_name}${suffix}"), "/[^a-z0-9]/", "")}${shard_key}", 0, 24)
— so sasample + abcd + sb01 gives sasampleabcdsb01.
Pinning matters more than it looks: because the storage tier config below names
those accounts, a random suffix that changes forces a matching change in app_config.
Hardcoding account names as literals alongside a generated suffix is how drift starts.
2. Storage identity injection
Every managedIdentityClientId in the storage tier document must be the client
ID of the identity that objectstore was given permission for. Interpolate the
value at the point of authoring:
managedIdentityClientId = azurerm_user_assigned_identity.app_identity.client_id
Account names come from the module rather than from prose. storage_accounts
is sensitive because it carries access keys, so unwrap it before using it in
a non-sensitive expression:
object_account_names = {
for shard_key, sa in nonsensitive(module.object_store.storage_accounts) :
shard_key => sa.name
}
Table names are not exported in a directly usable shape, but the rule is fixed:
the table mirrors the container with - stripped.
storage_table_names = {
for shard_key, containers in local.object_shards :
shard_key => [for container in keys(containers) : replace(container, "-", "")]
}
module.object_store.tables also exposes the created table names if you would
rather read them back than recompute the rule.
The queue writer follows the same rule with a different identity. The queue
module creates its own publisher identity and grants only that one send access,
so the writer's managedIdentityClientId is the publisher's client ID — not the
storage accessor's — and the evtnotify job must carry the publisher, which is
why evtnotify is in queue_job_names:
queue_writer = jsonencode({
type = "azure-storage-queue"
configuration = {
accountName = module.queue.storage_account_name
managedIdentityClientId = local.queue_publisher.client_id
queueName = module.queue.queue_name
messageEncoding = "text"
}
})
There is no connection string: the queue account is created with
shared_access_key_enabled = false, so it has no keys to build one from. See
Wiring the Queue Writer.
3. Per-app tag merge
compute demands a tags map on every entry of apps and jobs. Prepend the
resource group tags, repeating the merge per entry since apps and jobs are
indexed by different keys:
apps = {
kastoria = {
# ...
tags = merge(local.rg_tags, { app = "KASTORIA-HEALTH", submodule = "compute" })
}
}
4. Mount identity derivation
If you attach file store shares, the mount wiring is a fifth transformation — see the appendix. The sample has no file store, so it is the one step the configuration does not demonstrate.
The identity is yours to create
The storage-accessor user-assigned identity (id-app-storage-accessor) is not
owned by any component module — the environment creates it and hands the
principal ID to the storage modules and the client ID to the apps. Composing
directly, you must remember it exists and that it feeds four places:
| Consumer | Input |
|---|---|
objectstore |
user_managed_identity_principal_id → Blob + Table data RBAC |
filestore |
user_managed_identity_principal_id → file share RBAC |
compute |
per-app user_managed_storage_app = { id, client_id } |
app_config.storage_tiers |
managedIdentityClientId per shard |
Which apps and jobs get the identity is a contains() over two name lists:
apps_final = {
for k, v in local.apps : k => merge(v,
contains(local.storage_app_names, k) ? { user_managed_storage_app = local.storage_identity } : {}
)
}
The queue publisher identity (umi-sample-queue-publisher) is the opposite
case: the queue module creates it, so you do not, but you still decide which
workloads carry it. It feeds two places:
| Consumer | Input |
|---|---|
compute |
per-job user_managed_queue_app = { id, client_id } from publisher_id / publisher_client_id |
app_config.queue_writer |
managedIdentityClientId from publisher_client_id |
queue_job_names selects the jobs independently of storage_job_names.
evtnotify is in both lists and carries both identities: AZURE_CLIENT_ID
(set by user_managed_storage_app) selects the storage identity for the storage
tiers, and the explicit managedIdentityClientId selects the publisher for the
queue. Because the storage accessor has no queue role, a workload in
storage_*_names alone cannot publish.
jobs_final = {
for k, v in local.jobs : k => merge(v,
contains(local.storage_job_names, k) ? { user_managed_storage_app = local.storage_identity } : {},
contains(local.queue_job_names, k) ? { user_managed_queue_app = local.queue_publisher } : {}
)
}
Prerequisites: Key Vault secrets for the mTLS proxy
The mTLS proxy reads its trust material out of the environment's Key Vault, so
these secrets must exist before you plan/apply the gateway module. Create
them in the following way:
- Create the Azure Key Vault (apply
secretsmodule) including your IP Address inallowed_ips - Give yourself the
Key Vault Secrets Officerrole - Add the following secrets to the Key Vault
- Apply the
gatewaymodule
| Secret name | Read by | Renamable? |
|---|---|---|
callerCACert |
data.azurerm_key_vault_secret.mtls_ca_certificate |
yes — via mtls_ca.certificate_name |
callerCACertPassword |
data.azurerm_key_vault_secret.mtls_ca_password |
yes — via mtls_ca.password_secret |
kastoriaMTLSClientRoles |
ACA secret injection into the proxy app | no — hardcoded literal |
Do not model these as azurerm_key_vault_secret resources in the same
configuration: the gateway module reads them with data. blocks at plan
time while resources create at apply time, so a self-bootstrapping
configuration cannot plan on its first run.
Three gateway inputs also become effectively required once mtls_ca is set:
key_vault_id (enforced by a precondition), key_vault_name (not enforced —
without it the secret URI fails with "Invalid template interpolation value"), and
user_assigned_identity_id (not enforced, needed for runtime resolution).
Gotchas that cost you a debugging session
cloudflared_image is required even with the tunnel off. It has no
default and tofu validate fails with "required variable not set" before any
count is evaluated — the value is never used when
cloudflared_enabled = false, but it must be supplied.
proxy_enabled gates only the reverse proxy. mtls_ca != null &&
mtls_proxy_image != null gates the mTLS proxy independently, so an mTLS-only
gateway is a legal configuration — which is what the sample deploys.
var.domain is still required: it flows through the upstream address locals
into the mTLS proxy's environment even though the addressed apps' internal
FQDNs are only consumed by the proxy that is not being created.
enable_grafana defaults to true, which makes seven optional inputs
mandatory. The telemetry module validates this with a readable error, and it
also selects a different collector config, so it is not merely an additive
flag. With the flag off you configure no grafana provider block at all —
although the provider is still pulled into tofu init by the module's
required_providers, so the plugin must be downloadable.
webapi_app_name is a verbatim string, not a lookup. compute
interpolates it straight into API_BACKEND_URL = "http://<value>" and
API_HOST_NAME = "<value>". It does not index var.apps, so a typo or a
missing app produces a silently wrong environment variable rather than an
error. Contrast ingress_config.cors_app_name, which does index a computed
map and hard-fails at plan time if the key is absent. The sample omits
webapi_app_name entirely.
dicomweb_app_name is accepted and ignored. It is declared in the compute
module's variables and referenced nowhere in the module.
storage_access_keys is an all-or-nothing override, not a merge. The
compute module uses the map exclusively when it has any entry, and then indexes
it directly with each derived mount ID. A partial map fails the plan with
Invalid index. When nothing declares share_mounts, the empty default is
safe.
gateway_subnet_id is required and unread. compute declares it with no
default but never references it. You must still create a gateway subnet and
pass its ID.
nonsensitive() is required to reuse storage outputs. Both
objectstore.storage_accounts and filestore.file_shares are marked
sensitive, so using an account name in a jsonencoded config needs an
explicit unwrap.
A pinned project_prefix does not decouple you from object_store. It
fixes the name formula, but the tier config still reads storage_accounts
for the rendered names. Hardcoding account names as literals alongside a
pinned prefix would work, but is drift-prone.
The deploying principal needs permission to create queues. Creating a queue
is a management action (Microsoft.Storage/storageAccounts/queueServices/queues/write),
not a data action. Subscription Owner covers it; a deployer that is not an
Owner needs at least Storage Queue Data Contributor. Grant the role
alongside your other deployer roles, not in the sample.
The first apply that creates the queue account can fail. The account is
created with its firewall set to deny, and the queue is created through the
account's own endpoint. If the IP running OpenTofu is not in allowed_ips —
typically a CI runner whitelisted per account — azurerm_storage_queue.events
fails with a 403 after the account is created. Admit the runner IP on the new
account and apply again; no rollback is needed. See
CI Firewall Registration.
queue_reader_ips seeds the queue firewall once. The queue module
ignores later ip_rules changes, so a reader range added to the variable after
the first apply has no effect and no plan diff. Add it with
az storage account network-rule add instead; see
Onboarding a Reader.
Running the sample
With a checkout of the sample configuration:
docker login acrmerkalisdist0c66.azurecr.io # scoped token; OpenTofu reads this file
tofu init -backend=false # backend block is commented out on purpose
tofu fmt -check
tofu validate
tofu plan needs a real subscription, an ACR containing kastoria-ohif,
kastoria-health, kastoria-smart-launch-api, kastoria-consoleapi,
kastoria-consoleui, kastoria-studyprocessor, kastoria-eventnotification,
kastoria-proxy and kastoria-proxy-mtls, and the Key Vault secrets listed
above.
Appendix: file store mounts
The sample has no module.file_store. This is the code that adds one; it
cannot be pasted in alone — share_mounts and storage_access_keys must
arrive together or the plan fails.
module "file_store" {
source = "${local.modules_base}/filestore?tag=${local.modules_version}"
base_name = var.project_name
suffix = local.project_prefix
resource_group_name = azurerm_resource_group.env.name
location = azurerm_resource_group.env.location
allowed_subnet_ids = [
module.networking.created_subnets["storage"].id,
module.networking.created_subnets["compute"].id
]
shards = { "s00" = { quota_gb = 100 } }
user_managed_identity_principal_id = azurerm_user_assigned_identity.app_identity.principal_id
allowed_ips = var.allowed_ips
tags = local.rg_tags
}
locals {
# Which apps consume the shares created by `module.file_store`.
mount_app_names = ["kastoria", "smartlaunchapi"]
# Non-sensitive metadata drives for_each; the sensitive access keys travel in
# a separate map so they never taint a for_each key.
file_mounts = {
for k, v in module.file_store.file_shares_config : k => merge(v, { mount = ".storage" })
}
# Must match the compute module's mount-ID derivation exactly, or the mount
# IDs will not line up with the keys compute looks up.
file_mount_ids = {
for k, v in local.file_mounts : k =>
substr("st-${substr(md5("${v.storage_account}-${v.name}"), 0, 6)}-${v.storage_account}", 0, 32)
}
file_access_keys = {
for k, v in module.file_store.file_shares : local.file_mount_ids[k] => v.access_key
}
apps_with_mounts = {
for k, v in local.apps_final : k => merge(v,
contains(local.mount_app_names, k) ? { share_mounts = local.file_mounts } : {}
)
}
}
Then pass storage_access_keys = local.file_access_keys to module.compute
instead of {}, and make apps = local.apps_with_mounts. Add
depends_on = [module.file_store] to module.compute if you hit a race on the
environment storage resource — the mount metadata reference usually covers it.
REST APIs
REST APIs
This section documents the REST interfaces of the Kastoria Health imaging services: the DICOMweb Studies Service, the viewer-token authorization that gates its read interfaces, and the SMART on FHIR service that launches the viewer and issues those tokens.
DICOMweb
The Kastoria Health DICOMweb Service exposes medical imaging data over the DICOMweb REST API as defined in DICOM PS3.18. It supports the Studies Service transactions:
| Transaction | Operation | Documentation |
|---|---|---|
| Store | STOW-RS | STOW-RS — Store Instances |
| Retrieve | WADO-RS | WADO-RS — Retrieve Instances |
| Search | QIDO-RS | QIDO-RS — Search for Instances |
Note: The specifics of configuring the DICOMweb service will be provided in the associated Docker image page.
Service base URL
The service base URL follows the pattern:
https://{host}/
The service also provides two endpoints outside the Studies Service:
| Method | Path | Description |
|---|---|---|
| GET | /health |
Liveness check |
| GET | / |
Basic server information |
Common conventions
- DICOM JSON — Metadata and search responses use the DICOM JSON format
(PS3.18 Annex F), returned as
application/dicom+json. Attribute names are encoded as either a DICOM keyword (PatientID) or an Attribute ID (00100020). - Transfer syntaxes — A fixed set of transfer syntaxes is recognized for ingest-time and retrieve-time transcoding. See Transfer syntax support.
- Character sets — All DICOM-defined character sets are supported.
JWT Authorization
Authentication of the DICOMweb read interfaces (WADO-RS, QIDO-RS) with viewer
bearer tokens: the token format and claims, study scoping, the 401/403
responses, and client guidance. See
JWT Authorization.
SMART on FHIR
The smart-launch-api service implements the SMART on FHIR App Launch EHR
Launch sequence: an EHR launches the OHIF viewer with study context, and the
service mints the viewer tokens the DICOMweb read interfaces accept. See
SMART on FHIR — smart-launch-api.
Examples in this section
The pages in this section contain worked examples — requests, response
bodies, redirects, and the Warning header fields the DICOMweb service uses
to report partial results.
DICOMweb
STOW-RS — Store Instances
The Store transaction (PS3.18 §10.5)
uses the POST method to store representations of studies, series, and
instances contained in the request payload.
| Method | Path | Description |
|---|---|---|
| POST | /studies |
Store instances |
| POST | /studies/{study} |
Store instances for a specific study |
The study path parameter corresponds to the DICOM attribute
StudyInstanceUID (0020,000D). When it is specified, any instance in the
payload that does not belong to that study is reported as a failure with
failure reason 272 (PS3.18 §6.6.1.2).
Authorization
Store requests are not subject to viewer-token authorization, even when it is enabled for the read interfaces. See JWT Authorization.
Request
Content-Type
The request payload must use one of the following Content-Type headers:
multipart/related; type="application/dicom"— one part per DICOM instance; use this to store multiple instances in one requestapplication/dicom— a single DICOM instance
A multipart/related payload that names no boundary results in
500 (Internal Server Error).
Required instance attributes
Each DICOM file must be parseable as a DICOM Part 10 file, and the following elements must be present:
| Tag | Attribute |
|---|---|
| (0020,000D) | StudyInstanceUID |
| (0020,000E) | SeriesInstanceUID |
| (0008,0018) | SOPInstanceUID |
| (0002,0010) | TransferSyntaxUID |
Notes:
SOPClassUID(0008,0016) is extracted on a best-effort basis; instances lacking it are still accepted- No UID format or length validation is performed beyond presence checks
- Files that fail parsing or are zero-length are reported in the
FailedSOPSequencewith failure reason272
Ingest-time transcoding
Unlike a pass-through store, the service may transcode pixel data at ingest time according to rules evaluated against the instance's metadata (modality, transfer syntax, frame parameters such as bits allocated/stored, rows, columns, and photometric interpretation).
By default, instances of modalities CR, CT, DX, MG, and MR — and
losslessly-encoded NM, OT, PT, RF, SM, and US instances — are transcoded to
High-Throughput JPEG 2000 with RPCL Options (Lossless Only,
1.2.840.10008.1.2.4.202). All other DICOM attributes are stored as provided.
See Transfer syntax support for the full list of
recognized transfer syntaxes and the rule table.
Transcoding can be disabled per request with the header:
| Header | Accepted values | Description |
|---|---|---|
x-kastoria-disable-transcode |
true |
Store instances in their original transfer syntax without transcoding |
Response
Responses are always returned as application/dicom+json, populated as a
DICOM dataset with the following elements:
| Tag | Name | Description |
|---|---|---|
| (0008,1190) | RetrieveURL |
The retrieve URL of the study, if StudyInstanceUID was provided and at least one instance was stored successfully |
| (0008,1198) | FailedSOPSequence |
The sequence of instances that failed to store |
| (0008,1199) | ReferencedSOPSequence |
The sequence of stored instances |
Each dataset in FailedSOPSequence has the following elements:
| Tag | Name | Description |
|---|---|---|
| (0008,1150) | ReferencedSOPClassUID |
The SOP class UID of the instance that failed to store |
| (0008,1155) | ReferencedSOPInstanceUID |
The SOP instance UID of the instance that failed to store |
| (0008,1197) | FailureReason |
The reason code why this instance failed to store |
Each dataset in ReferencedSOPSequence has the following elements:
| Tag | Name | Description |
|---|---|---|
| (0008,1150) | ReferencedSOPClassUID |
The SOP class UID of the instance that was stored (omitted when unavailable) |
| (0008,1155) | ReferencedSOPInstanceUID |
The SOP instance UID of the instance that was stored |
| (0008,1190) | RetrieveURL |
The retrieve URL of this instance on the service |
Retrieve URLs
RetrieveURL tags are omitted entirely when the base URL cannot be determined.
The authoritative base URL for retrieval from the DICOMweb service is configured
in the service deployment. As RetrieveURL is dereferenced by whoever receives
it, it will never be built from an unverified request header.
If the base URL is not configured for a deployment, store responses from that
service will carry no (0008,1190) at either level. The store itself still
succeeds and the response is otherwise unchanged — the instances are stored and
listed in the ReferencedSOPSequence, without a URL to fetch them back by.
For POST /studies (without a study UID in the path), the top-level study
RetrieveURL is only included when all stored instances belong to a single
study.
Failure reason codes
| Code | Description |
|---|---|
272 (0x0110) |
Processing failure — used for all store failures, including parse failures, zero-length parts, storage errors, and instances whose StudyInstanceUID does not match the study UID stated in the request path |
Status codes
| Code | Description |
|---|---|
200 (OK) |
All the SOP instances in the request were stored successfully |
202 (Accepted) |
Some instances were stored successfully, others failed. Details are in the response body |
400 (Bad Request) |
The provided Content-Type is neither multipart/related nor application/dicom |
409 (Conflict) |
None of the instances in the store transaction request were stored |
500 (Internal Server Error) |
The server encountered an unexpected error, including a multipart/related Content-Type that names no boundary |
Examples
Store a single instance
curl -X POST "http://localhost:3000/studies" \
-H "Content-Type: application/dicom" \
-H "Accept: application/dicom+json" \
--data-binary @instance.dcm
Store multiple instances into a specific study
curl -X POST "http://localhost:3000/studies/1.2.840.113619.2.55.3.604688119.868.1234567890.1" \
-H 'Content-Type: multipart/related; type="application/dicom"; boundary=BOUNDARYZ' \
-H "Accept: application/dicom+json" \
--data-binary $'--BOUNDARYZ\r\nContent-Type: application/dicom\r\nContent-Transfer-Encoding: binary\r\n\r\n' \
--data-binary @file1.dcm \
--data-binary $'\r\n--BOUNDARYZ\r\nContent-Type: application/dicom\r\nContent-Transfer-Encoding: binary\r\n\r\n' \
--data-binary @file2.dcm \
--data-binary $'\r\n--BOUNDARYZ--\r\n'
Successful response
All instances stored. The study-level RetrieveURL is present when the base
URL is configured for the deployment:
{
"00081190": { "vr": "UR", "Value": ["https://dicom.example.com/studies/1.2.840.113619.2.55.3.604688119.868.1234567890.1"] },
"00081199": {
"vr": "SQ",
"Value": [
{
"00081150": { "vr": "UI", "Value": ["1.2.840.10008.5.1.4.1.1.2"] },
"00081155": { "vr": "UI", "Value": ["1.2.840.113619.2.55.3.12345.1"] },
"00081190": {
"vr": "UR",
"Value": [
"https://dicom.example.com/studies/1.2.840.113619.2.55.3.604688119.868.1234567890.1/series/1.2.840.113619.2.55.3.604688119.868.1234567890.2/instances/1.2.840.113619.2.55.3.12345.1"
]
}
}
]
}
}
Partial-failure response
202 (Accepted) — one instance stored, one failed. The failure appears in
FailedSOPSequence with failure reason 272:
{
"00081190": { "vr": "UR", "Value": ["https://dicom.example.com/studies/1.2.840.113619.2.55.3.604688119.868.1234567890.1"] },
"00081198": {
"vr": "SQ",
"Value": [
{
"00081150": { "vr": "UI", "Value": ["1.2.840.10008.5.1.4.1.1.2"] },
"00081155": { "vr": "UI", "Value": ["1.2.840.113619.2.55.3.12345.2"] },
"00081197": { "vr": "US", "Value": [272] }
}
]
},
"00081199": {
"vr": "SQ",
"Value": [
{
"00081150": { "vr": "UI", "Value": ["1.2.840.10008.5.1.4.1.1.2"] },
"00081155": { "vr": "UI", "Value": ["1.2.840.113619.2.55.3.12345.1"] },
"00081190": {
"vr": "UR",
"Value": [
"https://dicom.example.com/studies/1.2.840.113619.2.55.3.604688119.868.1234567890.1/series/1.2.840.113619.2.55.3.604688119.868.1234567890.2/instances/1.2.840.113619.2.55.3.12345.1"
]
}
}
]
}
}
Transfer syntax support
The following transfer syntaxes are recognized for ingest-time transcoding decisions. Transfer syntaxes not in this list are stored as-is (no transcoding is attempted). The same set governs retrieve-time transcoding.
Uncompressed:
| UID | Name |
|---|---|
1.2.840.10008.1.2 |
Implicit VR Little Endian |
1.2.840.10008.1.2.1 |
Explicit VR Little Endian |
1.2.840.10008.1.2.2 |
Explicit VR Big Endian |
Compressed:
| UID | Name |
|---|---|
1.2.840.10008.1.2.5 |
RLE Lossless |
1.2.840.10008.1.2.4.50 |
JPEG Baseline Process 1 |
1.2.840.10008.1.2.4.57 |
JPEG Lossless, Non-Hierarchical |
1.2.840.10008.1.2.4.70 |
JPEG Lossless, Non-Hierarchical, First-Order Prediction |
1.2.840.10008.1.2.4.80 |
JPEG-LS Lossless |
1.2.840.10008.1.2.4.81 |
JPEG-LS Lossy |
1.2.840.10008.1.2.4.90 |
JPEG 2000 Lossless Only |
1.2.840.10008.1.2.4.91 |
JPEG 2000 |
1.2.840.10008.1.2.4.110 |
JPEG XL Lossless |
1.2.840.10008.1.2.4.112 |
JPEG XL |
1.2.840.10008.1.2.4.201 |
High-Throughput JPEG 2000 Lossless Only |
1.2.840.10008.1.2.4.202 |
High-Throughput JPEG 2000 with RPCL Options Lossless Only |
1.2.840.10008.1.2.4.203 |
High-Throughput JPEG 2000 |
Ingest transcoding rules
At store time, the first matching rule determines the target transfer syntax. If the instance is already in the target syntax (or no rule matches), it is stored unchanged:
| Modality | Condition | Target transfer syntax |
|---|---|---|
| CR, CT, DX, MG, MR | Always | 1.2.840.10008.1.2.4.202 (HTJ2K RPCL Lossless Only) |
| NM, OT, PT, RF, SM, US | Source transfer syntax is lossless | 1.2.840.10008.1.2.4.202 (HTJ2K RPCL Lossless Only) |
Transcoding can be disabled per request with the
x-kastoria-disable-transcode: true header.
See also
- WADO-RS — Retrieve Instances — the stored instances are retrieved by reference using the URLs returned here
- QIDO-RS — Search for Instances — note that a stored study becomes searchable only after background processing has completed
WADO-RS — Retrieve Instances
The Retrieve transaction (PS3.18 §10.4) retrieves stored studies, series, instances, frames, metadata, rendered images, and thumbnails by reference.
| Method | Path | Description |
|---|---|---|
| GET | /studies/{study} |
Retrieve all instances within a study |
| GET | /studies/{study}/metadata |
Retrieve metadata for all instances within a study |
| GET | /studies/{study}/series/{series} |
Retrieve all instances within a series |
| GET | /studies/{study}/series/{series}/metadata |
Retrieve metadata for all instances within a series |
| GET | /studies/{study}/series/{series}/instances/{instance} |
Retrieve a single instance |
| GET | /studies/{study}/series/{series}/instances/{instance}/metadata |
Retrieve metadata for a single instance |
| GET | /studies/{study}/series/{series}/instances/{instance}/frames/{frames} |
Retrieve one or many frames from a single instance (comma-separated frame numbers, 1-indexed) |
| GET | /studies/{study}/series/{series}/instances/{instance}/rendered |
Retrieve an instance rendered into an image format (renders frame 0) |
| GET | /studies/{study}/series/{series}/instances/{instance}/frames/{frames}/rendered |
Retrieve frame(s) rendered into an image format |
| GET | /studies/{study}/series/{series}/instances/{instance}/thumbnail |
Retrieve a thumbnail of an instance (renders frame 0) |
| GET | /studies/{study}/series/{series}/instances/{instance}/frames/{frames}/thumbnail |
Retrieve thumbnail(s) of frame(s) |
Authorization
When viewer-JWT verification is enabled for the deployment, every request to
this interface must carry Authorization: Bearer <jwt>, and the study named
in the path must be included in the token's scope. See
JWT Authorization for the token format, scope
semantics, and status codes.
Retrieve instances within a study, series, or a single instance
The following Accept headers are supported:
multipart/related; type="application/dicom"application/dicomapplication/**/*(defaults tomultipart/related; type="application/dicom")
Transfer syntax negotiation
The transfer-syntax Accept-header parameter
(PS3.18 §8.7.3.5.2) is supported. When a specific transfer syntax is requested
and differs from the stored one, each instance is transcoded on the fly before
being returned. transfer-syntax=* (or omitting the parameter) returns
instances in their stored transfer syntax — which may be the result of
ingest-time transcoding.
Each part's Content-Type carries a transfer-syntax parameter identifying
the actual returned syntax (PS3.18 §8.7.9).
Transcoding is available between any pair of
supported transfer syntaxes. A requested
transfer syntax that is unsupported, or for which no codec is available,
results in 406 (Not Acceptable).
Retrieve frames
Frame numbers are 1-indexed and comma-separated in the path.
The following Accept headers are supported:
multipart/related— returnsmultipart/related; type="<frame media type>"with the appropriate media type based on the instance's transfer syntaxapplication/octet-streamapplication/**/*
The frame media type is derived from the returned transfer syntax:
| Media type | Transfer syntax |
|---|---|
application/octet-stream |
Uncompressed |
image/dicom-rle |
RLE Lossless |
image/jpeg |
JPEG Baseline or JPEG Lossless |
image/jls |
JPEG-LS |
image/jp2 |
JPEG 2000 |
image/jxl |
JPEG XL |
image/jphc |
High-Throughput JPEG 2000 |
Notes:
- A single-part response carries the frame media type, not
application/octet-stream. A request for one frame underAccept: */*against a JPEG-encoded instance is answeredContent-Type: image/jpeg; only an instance in an uncompressed transfer syntax is answeredapplication/octet-stream. The bytes are the stored frame, and the header names what they actually are. - A request naming more than one frame is always answered as
multipart/related, whatever theAcceptheader allows, because separate frames need separate body parts (PS3.18 §10.4.3). - The
Acceptheader is checked against every frame media type this service can produce, not against the one this instance will produce. A request withAccept: application/octet-streamfor a JPEG-encoded frame is therefore answered200withContent-Type: image/jpegrather than406. A client that can only decode one encoding should name the transfer syntax it wants with thetransfer-syntaxAccept-header parameter, which does govern what comes back. That parameter is read from amultipart/related,application/dicom,application/*or*/*media range only — carried on a concrete image media type such asimage/jpeg, it is ignored.
Transfer syntax negotiation
The transfer-syntax Accept-header parameter
(PS3.18 §8.7.3.5.2) is supported. When a specific transfer syntax is requested
and differs from the stored one, each frame is transcoded on the fly before
being returned, and the frame media type reflects the target syntax.
transfer-syntax=* (or omitting the parameter) returns frames in their stored
transfer syntax — which may be the result of
ingest-time transcoding.
Transcoding is available between any pair of
supported transfer syntaxes. A requested
transfer syntax that is unsupported, or for which no codec is available,
results in 406 (Not Acceptable).
Retrieve metadata (for study, series, or instance)
The Accept header must allow application/dicom+json — application/*,
*/*, or no Accept header at all are accepted.
Metadata is returned in DICOM JSON format as defined in PS3.18 Annex F.
- Compression: Metadata responses are served gzip-compressed when the
client sends
Accept-Encoding: gzip. - Bulk pixel data (
PixelData, 7FE0,0010) is never included in metadata responses; retrieve it via the frames or instance endpoints. - ETag / cache validation is not supported for metadata endpoints.
Retrieve rendered images (for instance or frame)
The following Accept headers are supported:
image/pngimage/jpegimage/bmpimage/*(defaults to PNG)*/*(defaults to PNG)multipart/relatedorapplication/octet-stream(defaults to PNG) — accepted so a client that names only a container gets a rendered image rather than a406
The service renders the specified instance or frame(s) into the requested
image format. For instance endpoints, frame 0 is rendered. Multiple frames are
returned as multipart/related with individual image parts.
| Parameter | Format | Description |
|---|---|---|
viewport |
vw,vh[,sx,sy,sw,sh] |
Scaling dimensions (width and height are required positive integers). Crop parameters (sx, sy, sw, sh) are accepted but currently ignored |
window |
center,width,function |
Windowing parameters. center and width must be numbers; function must be linear or sigmoid |
quality |
1-100 |
JPEG quality (integer 1–100, applies only to JPEG output) |
Image formats are selected in priority order: PNG > JPEG > BMP.
Retrieve thumbnails (for instance or frame)
The following Accept headers are supported:
image/jpeg(default per the DICOM spec)image/pngimage/bmpimage/*(defaults to JPEG)*/*(defaults to JPEG)multipart/relatedorapplication/octet-stream(defaults to JPEG) — accepted so a client that names only a container gets a thumbnail rather than a406
The service generates a thumbnail of the specified instance or frame(s). For
instance endpoints, frame 0 is rendered. Multiple frames are returned as
multipart/related with individual image parts.
| Parameter | Format | Description |
|---|---|---|
viewport |
vw,vh |
Thumbnail dimensions (width and height are required positive integers). Defaults to 128,128 |
Image formats are selected in priority order: JPEG > PNG > BMP.
Status codes
| Code | Description |
|---|---|
200 (OK) |
All requested data was retrieved successfully |
400 (Bad Request) |
Invalid query parameters (e.g. malformed viewport, window, or quality value, or a non-integer frame number), or a /rendered or /thumbnail request naming more frames than the service is configured to render at once |
401* (Unauthorized) |
Viewer-JWT verification is enabled and the request carries no valid bearer token |
403* (Forbidden) |
A valid token that does not grant the requested study (the study is not in the token's scope) |
404 (Not Found) |
The specified DICOM resource could not be found, or the instance does not contain pixel data for rendered/thumbnail requests |
406 (Not Acceptable) |
The specified Accept header is not supported, or a requested transfer-syntax cannot be transcoded to |
500 (Internal Server Error) |
The server encountered an unexpected error (if a mid-stream error occurs after headers are sent, the connection is torn down instead) |
* See above for an explanation of how to authorize
using Viewer-JWT verification.
Examples
Retrieve all instances in a study
curl "http://localhost:3000/studies/1.2.840.113619.2.55.3.604688119.868.1234567890.1" \
[-H 'Authorization: Bearer <jwt-token>' \ ]
-H 'Accept: multipart/related; type="application/dicom"'
Response: multipart/related with one application/dicom part per instance.
Retrieve a single instance in a specific transfer syntax
curl "http://localhost:3000/studies/1.2.840.../series/1.2.840.../instances/1.2.840..." \
[-H 'Authorization: Bearer <jwt-token>' \ ]
-H 'Accept: multipart/related; type="application/dicom"; transfer-syntax=1.2.840.10008.1.2.4.202'
Each part's Content-Type names the transfer syntax actually returned:
Content-Type: multipart/related; type="application/dicom"; boundary=...;
--...
Content-Type: application/dicom; transfer-syntax=1.2.840.10008.1.2.4.202
Retrieve metadata for a study
curl "http://localhost:3000/studies/1.2.840.../metadata" \
[-H 'Authorization: Bearer <jwt-token>' \ ]
-H "Accept: application/dicom+json" \
-H "Accept-Encoding: gzip"
Response: DICOM JSON, one dataset per instance, without bulk pixel data (truncated to one dataset):
[
{
"00080016": {
"vr": "UI",
"Value": ["1.2.840.10008.5.1.4.1.1.2"]
},
"00080018": {
"vr": "UI",
"Value": ["1.2.840.113619.2.55.3.12345.1"]
},
"00100010": {
"vr": "PN",
"Value": [{ "Alphabetic": "DOE^JOHN" }]
},
"00080060": {
"vr": "CS",
"Value": ["CT"]
},
"00280010": {
"vr": "US",
"Value": [512]
},
"00280011": {
"vr": "US",
"Value": [512]
}
}
]
Retrieve frames
Retrieve frames 1 and 2 of a multiframe instance:
curl "http://localhost:3000/studies/1.2.840.../series/1.2.840.../instances/1.2.840.../frames/1,2" \
[-H 'Authorization: Bearer <jwt-token>' \ ]
-H "Accept: multipart/related"
A request naming more than one frame is answered as multipart/related, with
each part carrying the frame's media type. A request for a single frame
against a JPEG-encoded instance is answered as a single part:
HTTP/1.1 200 OK
Content-Type: image/jpeg
Retrieve a rendered image
Render frame 0 as PNG, scaled to 512×512, with windowing applied:
curl "http://localhost:3000/studies/1.2.840.../series/1.2.840.../instances/1.2.840.../rendered?viewport=512,512&window=40,400,linear" \
[-H 'Authorization: Bearer <jwt-token>' \ ]
-H "Accept: image/png" \
--output rendered.png
Retrieve a rendered frame as JPEG with quality control
curl "http://localhost:3000/studies/1.2.840.../series/1.2.840.../instances/1.2.840.../frames/1/rendered?viewport=256,256&quality=85" \
[-H 'Authorization: Bearer <jwt-token>' \ ]
-H "Accept: image/jpeg" \
--output frame1.jpg
Retrieve a thumbnail
curl "http://localhost:3000/studies/1.2.840.../series/1.2.840.../instances/1.2.840.../thumbnail?viewport=128,128" \
[-H 'Authorization: Bearer <jwt-token>' \ ]
-H "Accept: image/jpeg" \
--output thumbnail.jpg
See also
- JWT Authorization — bearer-token authentication and study scoping of this interface
- STOW-RS — Store Instances — transfer syntax support, including the ingest-time transcoding rules that determine the stored syntax
- QIDO-RS — Search for Instances — find the study, series, and instance UIDs to use in these paths
QIDO-RS — Search for Instances
Query based on ID for DICOM Objects (QIDO-RS) enables searching for studies and series by attributes (PS3.18 §10.6).
| Method | Path | Description |
|---|---|---|
| GET | /studies |
Search for studies |
| GET | /studies/{study}/series |
Search for series in a study |
The Accept header must allow application/dicom+json (application/*,
*/*, or no Accept header at all are accepted). Anything else is rejected
with 406 (Not Acceptable).
Authorization
When viewer-JWT verification is enabled for the deployment, every request to
this interface must carry Authorization: Bearer <jwt>, and results are
restricted to the studies named in the token's scope. See
JWT Authorization for the token format, scope
semantics, and status codes.
Searchability: stored is not searchable
Search queries are served from search indexes maintained automatically by a background study-processing stage after instances are stored.
Newly stored studies become searchable once this processing has completed. After studies have been stored, they are asynchronously processed by the kastoria-health platform. Queries for studies that have not yet been processed return an empty array. A client that stores a study and immediately searches for it must expect this and retry after the processing stage has caught up.
Search parameters
Study level
| Parameter | Support | Description |
|---|---|---|
StudyInstanceUID |
Direct lookup | Unique study identifier |
PatientID |
Exact match, case-insensitive | Patient identifier |
StudyDate |
Single date or range | Study date in YYYYMMDD format. Single date (20240101) or range (20240101-20240131, 20240101-, -20240131) |
PatientName |
Leading components, case-insensitive | Patient's name in DICOM PN form. The value names leading components and each matches whole: DOE matches DOE^JOHN, DOE^JOHN matches DOE^JOHN^Q but not DOE^JOHNSON. Matched against the alphabetic component group |
StudyTime |
Single time or range | Study time in DICOM TM format, HH[MM[SS[.FFFFFF]]]. Single time (143000) or range (080000-170000, 080000-, -170000) |
AccessionNumber |
Exact match, case-insensitive | Accession number |
StudyDescription |
Exact match, case-insensitive | Study description |
ModalitiesInStudy |
List; matches any one modality, ignoring case | Modality (e.g. CT, MR). A list separated by \ or , is an IN clause — the study matches when any of its modalities equals any listed value |
limit |
Supported (default 100, maximum 1000) | Maximum number of matches returned in one response |
offset |
Supported (default 0, maximum 1000) | Number of matches skipped before the first returned one. A value above the maximum is rejected with 400, not capped |
Series level
| Parameter | Support | Description |
|---|---|---|
Modality |
List; matches any one, ignoring case | Filter by modality. A list separated by \ or , is an IN clause |
SeriesInstanceUID |
Exact match | Filter by series UID (a UID has no case to ignore) |
SeriesNumber |
Exact match | Filter by series number |
limit |
Supported (default 100, maximum 1000) | Maximum number of matches returned in one response |
offset |
Supported (default 0, maximum 1000) | Number of matches skipped before the first returned one. A value above the maximum is rejected with 400, not capped |
Attribute IDs
Query parameter keys may be encoded as either of the following (PS3.18 §8.3.4.1):
| Value | Example |
|---|---|
{group}{element} |
0020000D |
{dicomKeyword} |
StudyInstanceUID |
Matching semantics
| Search type | Supported attributes | Example |
|---|---|---|
| Range query | StudyDate, StudyTime |
StudyDate=20240101-20240131 — inclusive range. Either bound may be omitted (e.g. StudyDate=20240101- or StudyDate=-20240131) |
| Exact match | StudyInstanceUID, PatientID, AccessionNumber, StudyDescription, ModalitiesInStudy |
PatientID=PATIENT001 |
| Leading components | PatientName |
PatientName=DOE matches DOE^JOHN — the value names leading PN components and each matches whole, so this is not an exact match on the full name |
Notes:
- A zero-length value is universal matching (PS3.4 §C.2.2.2.1):
PatientName=places no constraint and every study matches. It is neither an error nor an ignored parameter, so it carries noWarningheader field. - Matching ignores case for every attribute except
StudyInstanceUID, which is a UID:aa-1234andAA-1234are one identifier, andchrisandCHRISare one person. StudyTimevalues are compared by the instant they denote, so a query and a stored value written to different precision (090000and090000.000000) match.- The upper bound of a
StudyTimerange runs to the end of the unit it names, matching the way aStudyDateupper bound runs to the end of its day.-235959includes a study acquired at235959.500, and-0900includes one at090030. An explicit fraction names an instant instead, so-090030.000ends exactly there. - A
StudyTimenaming the whole day —000000-235959,000000-,-235959, or a bare-— narrows nothing and is treated as absent rather than searched for. - Each query parameter must be supplied at most once. A repeated parameter —
PatientID=A&PatientID=B— is rejected with400 (Bad Request)naming the parameter. - Wildcard matching is not supported. Wildcard characters (
*,?) are not treated as wildcards in any search parameter. Fuzzy matching is not supported either.
Pagination
Both search transactions implement response pagination per PS3.18 §8.3.4.4.
| Parameter | Default | Behavior |
|---|---|---|
offset |
0 | Number of matches skipped before the first returned match; an explicit value above 1000 is rejected with 400 |
limit |
100 | Maximum matches returned in one response; an explicit value above 1000 is capped |
maxResults (PS3.18 §8.3.4.4.1) is 1000 — the ceiling on an explicit
limit, so no single response returns more than 1000 matches. A request that
supplies no limit receives 100.
When matches remain beyond those returned, the response carries a Warning
header field:
Warning: 299 {service}: There are more additional results that can be requested
The header announces that a further page can be requested. It does not carry a count of the matches not returned.
Both parameters must be non-negative integers. A malformed value is rejected
with 400 (Bad Request) rather than ignored — silently dropping a limit
would return the full result set to a caller that asked for one page.
offset may not exceed 1000 — the same ceiling as limit, so a request
may skip no more than it may retrieve — and a larger one is rejected with
400 (Bad Request) rather than capped. Paging is offset-based with no cursor,
so reaching a match means loading every match in front of it. A caller needing
to go deeper should narrow the query rather than page further into an
unnarrowed one.
Search depth
A search that combines two or more matching attributes is limited in how far back it reaches, but only where it has to be. Naming anything selective — a patient identifier, an accession number — settles the search almost at once, and it is then answered in full however old the matches are. The limit is reached only when every attribute named is a broad one.
Where a search is limited, the response is 200 (OK) and carries a Warning
header field naming the earliest study date covered:
Warning: 299 {service}: Searched studies from 20241216 onward; earlier matches may exist
Results from that date onward are complete. Nothing is claimed about studies before it.
- A
PatientNamesearch names no date. Where such a search is limited, the warning states the limit without dating it:
Warning: 299 {service}: The search was limited before every matching study was examined; further matches may exist
Both spellings mean the same thing to a caller: the result set is not known to be complete. Only the first tells you from when it is.
-
A search on a single matching attribute is not limited at all, and neither is a search on none. Both are answered in full.
-
An empty result set carries the warning too, when the search was limited.
200 (OK)with an empty array and noWarningheader means no study matches.200 (OK)with an empty array and this warning means no study matches within the range searched. These are different answers, and they are distinguished by the header rather than by the payload. -
An explicit
StudyDatenarrows the search but does not remove the limit. It is the most effective thing a caller can supply: it bounds every attribute at once, so a search that names a date is normally answered in full and carries no warning whatever attributes accompany it. -
A caller needing a longer history for a broad combination of attributes should page through it by date, a range at a time.
Unsupported query parameters
A query parameter this service does not match on is accepted, ignored, and
announced. The response is 200 (OK) and carries a Warning header field
naming every parameter that was ignored:
Warning: 299 {service}: The following query parameters are not supported and were ignored: PatientSex\StudyID
This applies to both search transactions, and to any parameter other than that
level's matching attributes and the limit / offset controls — including an
attribute the service returns but cannot match on, and a parameter it does not
recognize at all. The result set is therefore wider than the request asked
for: the ignored parameter narrowed nothing.
A 400 is not used here. PS3.18 reserves it for a query the service cannot
parse, and PS3.4 §C.2.2.1.3 permits an Optional Key to be returned without
being matched on — so a well-formed query the service can only partly honor is
answered, with the shortfall stated.
Parameter names are separated by \, the DICOM multi-valued delimiter, rather
than by a comma, because Warning is itself a comma-delimited list field. A
name this service cannot repeat back is reported as (unprintable):
Warning: 299 {service}: The following query parameters are not supported and were ignored: PatientSex\(unprintable)
Both warnings are sent when both apply — a query naming an ignored attribute can still overflow its page — as two warn-values, the query's shortcoming before the response's:
Warning: 299 {service}: The following query parameters are not supported and were ignored: PatientSex
Warning: 299 {service}: There are more additional results that can be requested
Response
The response is an array of DICOM datasets in DICOM JSON format. Results are
ordered by study date and time, most recent first. One exception: a search on
PatientName that names fewer components than the stored name — SMITH
matching SMITH^JOHN — is ordered most-recent-first within each matching
patient, but is not ordered by date across several patients sharing those
leading components.
Default study-level attributes:
| Tag | Attribute |
|---|---|
| (0020,000D) | StudyInstanceUID |
| (0010,0020) | PatientID |
| (0010,0010) | PatientName |
| (0010,0030) | PatientBirthDate |
| (0010,0040) | PatientSex |
| (0020,0010) | StudyID |
| (0008,0020) | StudyDate |
| (0008,0030) | StudyTime |
| (0008,0050) | AccessionNumber |
| (0008,0061) | ModalitiesInStudy |
| (0008,1030) | StudyDescription |
| (0020,1206) | NumberOfStudyRelatedSeries |
| (0020,1208) | NumberOfStudyRelatedInstances |
Default series-level attributes:
| Tag | Attribute |
|---|---|
| (0020,000D) | StudyInstanceUID |
| (0020,000E) | SeriesInstanceUID |
| (0020,0011) | SeriesNumber |
| (0008,0005) | SpecificCharacterSet |
| (0008,0060) | Modality |
| (0008,0021) | SeriesDate |
| (0008,0031) | SeriesTime |
| (0008,103E) | SeriesDescription |
| (0008,0070) | Manufacturer |
| (0008,0080) | InstitutionName |
| (0008,0090) | ReferringPhysicianName |
| (0008,1090) | ManufacturerModelName |
| (0018,0015) | BodyPartExamined |
| (0018,1030) | ProtocolName |
| (0020,1209) | NumberOfSeriesRelatedInstances |
Note: The includefield parameter, to return additional attributes beyond
the defaults, is not currently supported.
Status codes
| Code | Description |
|---|---|
200 (OK) |
The response payload contains all matching resources (an empty array when there are no matches) |
400 (Bad Request) |
The query cannot be honored as written: a range that cannot match (a StudyDate range whose end precedes its start), a limit or offset that is not a non-negative integer, an offset above 1000, or a query parameter supplied more than once |
401* (Unauthorized) |
Viewer-JWT verification is enabled and the request carries no valid bearer token |
403* (Forbidden) |
A request for a study by UID, or for anything beneath it (series, instances, frames, metadata, etc.), names a Study Instance UID that is not in the token's scope |
406 (Not Acceptable) |
The specified Accept header does not allow application/dicom+json |
500 (Internal Server Error) |
The server encountered an unexpected error |
* See above for an explanation of how to authorize
using Viewer-JWT verification.
Response guarantees
- An empty result set is returned with
200 (OK)and an empty JSON array —204 (No Content)is not used. - A
200 (OK)always means the search ran to completion. A search that fails part-way through returns500, and never a partial or empty array with200— a client must be able to distinguish "no studies match" from "the search could not be completed". A query the service cannot satisfy because of how it is written returns400, so a caller can tell its own mistake from the server's. - A
200 (OK)does not by itself mean every parameter was applied. Two things narrow what the payload holds relative to what was asked, and both are stated in aWarningheader field rather than left for the client to infer: - A parameter this service does not match on is ignored, so the result set is wider than the request.
- Matches beyond
limitare not returned, so the result set is narrower than the whole match set.
A response carrying no Warning header field is complete in every sense:
every parameter was applied, no match was left behind, and no limit was
placed on how far back the search reached.
Examples
Search studies by patient name and date
curl "http://localhost:3000/studies?PatientName=DOE^JOHN&StudyDate=20240101" \
[-H 'Authorization: Bearer <jwt-token>' \ ]
-H "Accept: application/dicom+json"
Search studies by date range
curl "http://localhost:3000/studies?StudyDate=20240101-20240131" \
[-H 'Authorization: Bearer <jwt-token>' \ ]
-H "Accept: application/dicom+json"
Search studies by patient ID
curl "http://localhost:3000/studies?PatientID=PATIENT001" \
[-H 'Authorization: Bearer <jwt-token>' \ ]
-H "Accept: application/dicom+json"
Direct lookup by study UID
curl "http://localhost:3000/studies?StudyInstanceUID=1.2.840.113619.2.55.3.604688119.868.1234567890.1" \
[-H 'Authorization: Bearer <jwt-token>' \ ]
-H "Accept: application/dicom+json"
Paginated search
Skip the first 100 matches and return up to 100 more:
curl "http://localhost:3000/studies?PatientID=PATIENT001&offset=100&limit=100" \
[-H 'Authorization: Bearer <jwt-token>' \ ]
-H "Accept: application/dicom+json"
Search series within a study
curl "http://localhost:3000/studies/1.2.840.113619.2.55.3.604688119.868.1234567890.1/series?Modality=CT" \
[-H 'Authorization: Bearer <jwt-token>' \ ]
-H "Accept: application/dicom+json"
Example response
[
{
"0020000D": {
"vr": "UI",
"Value": ["1.2.840.113619.2.55.3.604688119.868.1234567890.1"]
},
"00100020": {
"vr": "LO",
"Value": ["PATIENT001"]
},
"00100010": {
"vr": "PN",
"Value": [{ "Alphabetic": "DOE^JOHN" }]
},
"00080020": {
"vr": "DA",
"Value": ["20240101"]
},
"00080030": {
"vr": "TM",
"Value": ["120000"]
},
"00080050": {
"vr": "SH",
"Value": ["ACC001"]
},
"00080061": {
"vr": "CS",
"Value": ["CT"]
},
"00081030": {
"vr": "LO",
"Value": ["Example Study Description"]
},
"00201206": {
"vr": "IS",
"Value": [1]
},
"00201208": {
"vr": "IS",
"Value": [100]
}
}
]
Reading the Warning header
A search naming an unsupported parameter and more matches than fit on one page succeeds, but carries two warnings:
curl -i "http://localhost:3000/studies?PatientSex=F&StudyDate=20240101-20240131" [ \
-H 'Authorization: Bearer <jwt-token>' ]
HTTP/1.1 200 OK
Content-Type: application/dicom+json
Warning: 299 kastoria-dicomweb: The following query parameters are not supported and were ignored: PatientSex
Warning: 299 kastoria-dicomweb: There are more additional results that can be requested
PatientSex is returned in the payload but cannot be matched on, so the
result set is wider than the query asked for; and further pages remain.
Notes for client implementers
- Do not treat
200 (OK)with an empty array as definitive without checking for aWarningheader field. A search limited to a window returns the same empty payload whether nothing matches at all or nothing matches recently. The header is the only distinction. - Treat a
299warning as information, not an error. It accompanies a successful response and does not indicate a failed or degraded search. A response may carry more than one — an ignored parameter, a limited window, and further pages available are independent conditions and can all apply at once. - To search a longer history, page by date rather than widening the range.
Take the date the warning names, and issue the next request with a
StudyDaterange ending the day before it. Repeat until a response arrives without the warning, or until far enough back to stop. Each request is bounded, and together they cover the history without any single request being expensive. - Supply a
StudyDatewhenever one is known. It bounds every attribute at once, which is both faster and the surest way to receive a complete answer rather than a limited one. - Prefer
StudyInstanceUIDwhen it is known. It is answered as a direct lookup, without reading any index.
See also
- JWT Authorization — bearer-token authentication and study scoping of this interface
- STOW-RS — Store Instances — how studies are stored before they become searchable
- WADO-RS — Retrieve Instances — fetch the studies and series a search returns
SMART on FHIR
SMART on FHIR — smart-launch-api
The smart-launch-api service implements the SMART on FHIR
App Launch v2.2 EHR Launch
sequence. It is the bridge between an EHR system and Kastoria's imaging
services: an EHR launches the OHIF DICOM viewer with study context, the
service resolves the FHIR ImagingStudy to a DICOM StudyInstanceUID, and —
when configured — mints the viewer JWT that authorizes the resulting
WADO-RS and QIDO-RS
requests (see JWT Authorization).
This page documents the service from a caller's perspective: what the EHR invokes, what the browser is redirected through, and how the viewer JWT is produced.
Service base URL
https://{host}/
| Method | Path | Description |
|---|---|---|
| GET | /smart/launch |
EHR launch entry point; starts the SMART flow |
| GET | /smart/callback |
OAuth callback; completes the launch |
| GET | /smart/error |
Error page rendered for every failure path |
The SMART endpoints are browser redirect endpoints, not data APIs. Responses
are 302 redirects; a caller never receives a JSON body from them. Failures
are surfaced by redirecting to the error page (see
Error handling) — except for unknown paths, which answer
404 with {"error": "not_found", ...}.
Prerequisites
Before an EHR can launch:
- Register the EHR in the Kastoria admin console's Smart Launch API page. Each entry records:
- EHR URL — the FHIR base URL the EHR sends as
iss - Client ID — the OAuth client ID registered with that EHR
- State Secret — auto-generated at record creation; used to encrypt
the OAuth
statetoken - Active flag — a deactivated record refuses launches
- The EHR must advertise
launch-ehrin its/.well-known/smart-configurationcapabilities and expose anauthorization_endpointandtoken_endpoint. - A signing key must be configured on this service if viewer JWTs are required (see Viewer JWT issuance).
The configuration store is an allowlist: iss is unauthenticated input from
a query string, so nothing is fetched from the EHR until the configuration
record for it has been found.
The EHR launch flow
EHR ──1──▶ /smart/launch?iss=…&launch=… (browser)
◀─2── 302 → EHR authorization endpoint (browser)
EHR auth ──3──▶ user authenticates, grants launch context
◀─4── 302 → /smart/callback?code=…&state=… (browser)
service ──5──▶ token exchange + ID token verification (server-to-server)
service ──6──▶ ImagingStudy → StudyInstanceUID resolution
◀─7── 302 → OHIF viewer (+ optional #kastoriaToken) (browser)
- The EHR initiates the launch by opening
/smart/launch?iss={fhir_base_url}&launch={launch_id}— typically a new browser tab or iframe.launchis the opaque context identifier the EHR generated; the service passes it back to the EHR's authorization endpoint unchanged. - The service redirects to the EHR's authorization endpoint with an
OAuth 2.0 authorization-code request. PKCE (S256) is mandatory, and the
stateparameter is an encrypted token (JWE) carrying the PKCE verifier and EHR endpoints — the service is fully stateless. - The user authenticates with the EHR and approves the launch context.
- The EHR redirects back to
/smart/callback?code={code}&state={state}. Theredirect_uriit was given is derived from the request the service received (see Redirect URI derivation). - The service exchanges the code for tokens (
authorization_codegrant with the PKCE verifier), and verifies the returnedid_tokensignature against the EHR's JWKS endpoint when the SMART configuration publishes ajwks_uri. - The service resolves context: it reads the
patientandimagingStudyvalues the token response carried, resolves the FHIRImagingStudyID to its DICOMStudyInstanceUID(via theurn:dicom:uididentifier), checks the study belongs to the launch patient, and verifies the study exists in processed DICOM storage. - The service redirects the browser to the OHIF viewer with the resolved study — and, when enabled, a viewer JWT in the URL fragment.
Endpoint reference
GET /smart/launch
Initiates the SMART App Launch flow. Called by the EHR.
| Parameter | Required | Description |
|---|---|---|
iss |
Yes | The EHR's FHIR server base URL |
launch |
Yes | Opaque launch identifier issued by the EHR |
Example:
GET /smart/launch?iss=https://ehr.example.com/fhir&launch=abc123
The service looks up the EHR's configuration record, fetches the EHR's
/.well-known/smart-configuration, confirms the launch-ehr capability,
generates PKCE parameters and the encrypted state token, and redirects
(302) to the EHR's authorization endpoint with:
| Parameter | Value |
|---|---|
response_type |
code |
client_id |
From the EHR's configuration record |
redirect_uri |
{service}/smart/callback (see Redirect URI derivation) |
scope |
launch openid profile patient/ImagingStudy.read |
launch |
The launch identifier, passed through |
state |
The encrypted state token |
aud |
The iss value |
code_challenge / code_challenge_method |
PKCE S256 challenge |
GET /smart/callback
The OAuth redirect URI. The EHR's authorization server sends the browser here after authentication.
| Parameter | Required | Description |
|---|---|---|
code |
Yes | Authorization code from the EHR |
state |
Yes | The state token issued by /smart/launch |
error |
No | Error code, when the EHR rejected the authorization |
error_description |
No | Human-readable error from the EHR |
On success the service verifies the state token against the issuing EHR's
State Secret, exchanges the code for tokens, validates the launch context,
resolves the study, and redirects (302) to the viewer:
{viewer-base-url}/viewer?StudyInstanceUIDs={uid}
and, when a viewer JWT was minted:
{viewer-base-url}/viewer?StudyInstanceUIDs={uid}#kastoriaToken={jwt}
The JWT is URL-encoded and placed in the fragment, so it stays out of
server access logs. The appropriate information (e.g., the
studyInstanceUIDs array) needs to be presented in future requests to
DICOMweb to provide access to the studies — see
JWT Authorization for how.
GET /smart/error
The single error page every failure path redirects to. It renders whatever the redirect carried:
| Parameter | Description |
|---|---|
title |
Short failure title (e.g. EHR Not Configured) |
message |
Human-readable explanation |
detail |
Optional extra detail, safe to display |
Viewer JWT issuance
When a viewer-JWT signing key is configured for the deployment,
/smart/callback mints a viewer JWT and appends it to the viewer redirect as
the kastoriaToken fragment. The token's format, claims, and how DICOMweb
verifies it are documented in
JWT Authorization — this page covers only how the
token is produced here:
- Claim sources —
subandehrIssare taken from the EHR's verified OIDC ID token;studyInstanceUIDscarries the resolved DICOMStudyInstanceUIDof the launched study.iss,aud, andexpare set from the service's viewer-JWT configuration. - Minting precondition — a token is only minted when the EHR's ID token
was signature-verified via its JWKS endpoint (
jwks_uripresent in the SMART configuration). If the EHR publishes nojwks_uri, the ID token is decoded without verification and no viewer JWT is produced — the launch proceeds without the fragment and a warning is logged. This is deliberate: signing an unverified identity would launder trust downstream. A missing token for such an EHR is expected behavior, not a fault.
Redirect URI derivation
The redirect_uri presented to the EHR's authorization server is derived per
request as {public origin}/smart/callback, where the public origin is the
one browsers actually reach. A deployment behind a reverse proxy must present
that origin consistently — a mismatch makes the EHR reject the callback as an
unregistered redirect URI.
Error handling
The SMART endpoints are redirects, so failures are conveyed by redirecting to
/smart/error with a title, message, and optional detail — never by a
JSON error body. The titles below are the title query parameter on the
/smart/error redirect, and every title the service can produce is listed here.
They are treated as part of this API, so they are safe to assert against; the
accompanying message and detail are human-readable, may be reworded, and on some
paths carry text from your EHR.
GET /smart/launch:
| Title | Shown when |
|---|---|
Invalid Launch Request |
iss or launch is missing |
EHR Not Configured |
No usable configuration record for the iss |
EHR Deactivated |
The record exists but is deactivated |
EHR Configuration Error |
The EHR's smart-configuration could not be retrieved |
Unsupported EHR |
The EHR does not advertise the launch-ehr capability |
Launch Failed |
Any other failure |
GET /smart/callback:
| Title | Shown when |
|---|---|
Authorization Failed |
The EHR redirected back with an error parameter |
Invalid Request |
code or state is missing |
Session Expired or Invalid |
The state token is malformed, expired, unverifiable, or names an unconfigured EHR |
Token Exchange Failed |
The code was invalid, expired, or already redeemed |
ID Token Validation Failed |
The id_token failed signature verification |
Missing Patient Context |
No patient in the token response |
Missing Imaging Study Context |
No imagingStudy in the token response |
Missing Launch Context |
Neither patient nor imagingStudy |
Imaging Study Not Found |
The study does not exist, is not processed, or belongs to another patient |
Launch Failed |
Any other failure |
Error detail hygiene: the service writes its own failure text rather than surfacing
internal exceptions — the two exceptions are Authorization Failed and
Token Exchange Failed, which relay what your EHR reported. The one page whose detail
names a study or patient (Imaging Study Not Found) goes only to the user who launched it
and is deliberately kept out of logs.
Security properties
- PKCE is mandatory (S256) on every authorization request.
- The OAuth
stateis an encrypted token (JWE,dir+A256GCM) keyed by the per-EHR State Secret: tamper-evident and unreadable, expiring after 5 minutes to match the authorization-code lifetime. No server-side session storage exists. - The configuration store is the allowlist — the
iss-named EHR is not contacted until a configuration record for it exists, so the endpoint cannot be used to probe arbitrary addresses. - ID token signatures are verified via the EHR's JWKS endpoint when published — the precondition for viewer-JWT minting described in Viewer JWT issuance.
- PHI-adjacent values are never logged: token values, state tokens, and
launch context identifiers appear in logs as presence only (
present: true/false).
Testing
Merkalis can provide a standalone EHR simulator for testing the full SMART launch / EHR flow. It acts as the launching EHR — initiating the launch, driving the authorization exchange, and loading the viewer with the resolved study.
See also
- JWT Authorization — how the viewer JWT minted by this service authorizes QIDO-RS / WADO-RS requests
- WADO-RS — Retrieve Instances
- QIDO-RS — Search for Instances
JWT Authorization — QIDO-RS / WADO-RS
The QIDO-RS and WADO-RS read interfaces support study-scoped access with
Authorization: Bearer viewer JWTs. Authorization is off by default: it is
enabled per deployment by configuring a token-verification key. When enabled,
every QIDO-RS / WADO-RS request must carry a valid viewer token and may only
reach studies named in that token.
This page describes the mechanism from both a client-implementer and an operator perspective.
Endpoints covered
| Endpoints | Authorization |
|---|---|
QIDO-RS — GET /studies, /studies/{study}/series |
Viewer JWT required when enabled |
WADO-RS — all GET retrieve endpoints (study, series, instance, frames, metadata, rendered, thumbnail) |
Viewer JWT required when enabled |
STOW-RS — POST /studies, POST /studies/{study} |
Never gated — ingestion authenticates out-of-band (mTLS proxy) |
The viewer token
The viewer JWT is minted by the smart-launch-api service at the end of a SMART on FHIR EHR launch flow. The DICOMweb service holds only the RS256 public half of the signing keypair and verifies tokens offline against it.
Verification is strict. A token must:
- be signed with RS256 (
algmust be RS256) - carry the expected
iss(issuer) claim - carry the expected
aud(audience) claim - not be expired (
expis enforced with no clock skew)
| Claim | Requirement | Purpose |
|---|---|---|
iss |
Must match the configured issuer | Names the trusted token service |
aud |
Must match the configured audience | Names this service |
exp |
Must be in the future | Expiry, enforced strictly |
sub |
Present | User identifier — audit attribution only |
ehrIss |
Present | EHR issuer that namespaced sub — audit attribution only |
studyInstanceUIDs |
Array of Study Instance UIDs | The authorization scope |
sub and ehrIss take part in no authorization decision. The scope is the
studyInstanceUIDs array; see below.
When enabled in the DICOMweb service, requests to the QIDO-RS or WADO-RS
services must include a valid signed JWT token. This token shall be provided
by including an Authorization header in the standard way:
Authorization: Bearer <jwt-token>
Study scope
The token's studyInstanceUIDs claim lists the Study Instance UIDs the holder
may reach:
- WADO-RS and series search — every endpoint whose path names a Study
Instance UID (
/studies/{study}/...) requires that study to appear in the token's scope. A valid token naming a different study is refused with403 (Forbidden). - Study search (
/studies) — results are intersected with the token's scope: only studies the token grants can be returned. - Wildcard — a scope containing the literal
'*'grants every study. The token is still required, but no per-study restriction applies. - Empty scope — grants nothing. Study search returns an empty array, and
direct study access is refused with
403. An empty scope is an authorization outcome, not an error.
Status codes
When authorization is enabled:
| Code | Meaning | Response body |
|---|---|---|
401 (Unauthorized) |
No Authorization: Bearer header, a malformed one, or a token that is invalid or expired (bad signature, wrong iss/aud, wrong algorithm, past exp) |
{"error": "Unauthorized"} |
403 (Forbidden) |
A valid token that does not grant the requested study | {"error": "Study not in scope"} |
A malformed Authorization header — missing token, extra segments — is
rejected rather than salvaged.
Examples
Authenticated request
curl "http://localhost:3000/studies?PatientID=PATIENT001" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9. ..." \
-H "Accept: application/dicom+json"
curl "http://localhost:3000/studies/1.2.840.../series/1.2.840.../instances/1.2.840.../metadata" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9. ..." \
-H "Accept: application/dicom+json"
Missing or invalid token
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"error":"Unauthorized"}
A client receiving 401 should obtain a fresh token from the launch flow and
retry once; persistent 401s indicate a misconfigured issuer, audience, or
signing keypair.
Study outside the token's scope
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"error":"Study not in scope"}
A 403 means the token itself is fine — the client must request a token whose
studyInstanceUIDs includes the study it wants.
Study search under a scoped token
A study search with a valid token returns only studies the token grants, and is indistinguishable in shape from an unscoped search:
curl "http://localhost:3000/studies?StudyDate=20240101-20240131" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9. ..." \
-H "Accept: application/dicom+json"
Studies the token does not grant are silently excluded. The Warning header
semantics described in QIDO-RS apply as normal.
Client implementer notes
- Send the header on every read request. There is no session or cookie fallback.
- Never place the token in a URL. It is a header value only; URLs reach access logs, proxy logs, and browser history.
- Treat
401and403differently.401asks for a new token;403asks for a different token (one whose scope names the study). - Request tokens with the narrowest useful scope. The scope is the set of studies the session may open; a wildcard token is only needed when the session genuinely browses all studies.
Operating the feature
| Variable | Required | Default | Description |
|---|---|---|---|
JWT_VERIFYING_KEY |
No | — (feature off) | RS256 PEM public key matching the token service's signing key. Its presence turns verification on. \n escape sequences are normalized to real newlines, so both dotenv multi-line and single-line escaped forms work. A value that is not a valid RSA public key — garbage, a private key pasted by mistake, or a key of another algorithm (e.g. EC) — fails startup. The private key must never reach this service. |
JWT_ISSUER |
No | kastoria-smart-launch |
Expected iss claim; a token with any other issuer is rejected with 401 |
JWT_AUDIENCE |
No | kastoria-health |
Expected aud claim; a token with any other audience is rejected with 401 |
- Key distribution is a deployment concern. The keypair is generated and
managed alongside the token service's
JWT_SIGNING_KEY; the operator places the same keypair's public half in this service and its private half in the token service. Startup fails fast on a misconfigured key so a bad deploy surfaces immediately rather than as the first user's401. - With the key unset the feature is off — no header is required and no scope is enforced, and a warning is logged at startup saying so.
- Tokens are PHI-adjacent and are never logged. The service records only
that a request was authenticated and the configured
iss/audconstants; scope denials are logged generically without naming the study or the user. Clients must keep tokens out of their own logs for the same reason.
See also
- QIDO-RS — Search for Instances
- WADO-RS — Retrieve Instances
- STOW-RS — Store Instances — exempt from viewer-token authorization by design
Integration
Integration
This section documents how to integrate with Kastoria Health beyond its REST APIs: the events Kastoria Health publishes as it ingests imaging studies, and what a consumer needs to receive and apply them.
Ingestion Manifest
Each time Kastoria Health processes a DICOM study, it publishes a Notification Event carrying the study's Ingestion Manifest — the DICOM and resource identifiers needed to map the study between systems. Events are delivered to an Azure Storage Queue in your tenant.
| Topic | Event types | Transport | Documentation |
|---|---|---|---|
study |
created, updated |
Azure Storage Queue | Study Notification Events (Ingestion Manifest) |
Common conventions
- JSON — Every event is one UTF-8 JSON object with camelCase keys;
acronyms are upper case (
studyInstanceUID,patientID). - Forward compatibility — Consumers must ignore keys they do not recognize. There is no schema-version field; an added key is not a breaking change.
- At-least-once delivery — Events can be duplicated and can arrive out of
order. Consumers order them by
eventTime, per study. - Protected health information — Every message contains protected health information (patient name, birth date and identifiers). Handle messages, logs and anything built from them accordingly.
Examples in this section
The pages in this section contain worked examples — event bodies, queue setup commands, and how a consumer applies events that arrive out of order.
Event Notification
Ingestion Manifest (Study Notification Events)
Each time Kastoria Health processes a DICOM imaging study, it publishes a Notification Event carrying the study's Ingestion Manifest: the set of DICOM and resource identifiers a third party needs to map the study between systems. Events are delivered to an Azure Storage Queue in your tenant, one event per message.
This page documents the feature from a consumer's perspective: when events are published, what an event contains, how the queue is set up and secured, and how a consumer reads and applies events.
| Transport | Message format | Delivery | Ordering |
|---|---|---|---|
| Azure Storage Queue | One JSON event per message | At least once | None — order by eventTime |
The ingestion pipeline
Studies are ingested through the STOW-RS endpoint. A study received there passes through the ingestion pipeline, a sequence of steps that each scale independently:
- Store. The DICOM instances are committed to Kastoria Health's versioned storage.
- Process. The stored study is prepared for use. Kastoria Health indexes it for DICOMweb query and retrieval, records its FHIR Patient resource, and builds the summary data that the Ingestion Manifest is drawn from.
A study is considered ingested once every step of the pipeline has completed for it. Kastoria Health may add steps in the future.
Events are not a searchability signal
Notification Events do not wait for the whole pipeline. A study's event is published as soon as Process has built the study's summary, so the DICOMweb indexes for it can still be building when the event arrives. An event confirms what it carries about the study; it is not a signal that the study can yet be queried or retrieved over DICOMweb (see Searchability: stored is not searchable).
Data quality
DICOM metadata fields are often created automatically by the devices or entered manually by the technologist, and the DICOM standard makes many fields optional. Kastoria Health publishes these values as received. It does not correct, normalize or reconcile them. Rigorous data extraction and data quality validation are essential before migrating data to Kastoria Health, so that the data ingested is of high quality and the mapping between systems is clean.
When events are published
Kastoria Health publishes a Notification Event once a study has been processed:
| Event type | Published |
|---|---|
created |
When the study is first processed |
updated |
Every time the study is processed again after that — for example, when further instances or series for the study arrive |
Each event carries the whole Ingestion Manifest as it stood after that change, never only what changed. The consumer can act on an event without calling Kastoria Health back.
- An
updatedevent can carry a manifest identical to the previous one. A change such as a new series of images changes the stored study without necessarily changing any Ingestion Manifest field. - Reprocessing that produces no change publishes nothing. Kastoria Health publishes only when the stored study actually changes.
- A study that has not been processed has no event. A study still being processed, or one that could not be processed, is published once processing succeeds.
- Events are published in periodic runs, so an event reaches the queue shortly after the study is processed, within the publishing interval configured for the deployment.
- Publishing starts from the beginning. When publishing is first enabled for a deployment, events are published for every study already processed, including an event for each earlier change to it.
- Delivery is at least once. The same event can be delivered more than once, and events can arrive in a different order from the one in which the changes happened. Applying events in order gives the one rule a consumer applies to handle both.
The Notification Event
Every event is one JSON object (UTF-8). A single event is about 1–3 KB.
{
"eventId": "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy",
"eventTopic": "study",
"eventType": "updated",
"eventTime": "2026-09-13T15:04:05.123Z",
"data": {
"imagingStudyResourceID": "img-study-e5f6g7h8",
"studyInstanceUID": "1.2.840.113619.2.55.3.604688119.971.1326810290.945",
"patientResourceID": "pat-a1b2c3d4",
"patientID": "MRN0012345",
"patientName": "Doe^Jane",
"patientBirthDate": "19700101",
"patientSex": "F",
"accessionNumber": "ACC20260913001",
"studyDate": "20260913",
"studyTime": "150405.123456",
"studyDescription": "CT CHEST W CONTRAST",
"studyModalities": ["CT", "SR"],
"referringPhysicianName": "Smith^John^^Dr."
}
}
All keys are camelCase, with acronyms in upper case (studyInstanceUID,
patientID).
Consumers must ignore keys they do not recognize. Kastoria Health may add
keys to the event or to data. There is no version or schema-version field;
an added key is not a breaking change.
Event fields
| Field | Type | Description |
|---|---|---|
eventId |
string | Identifies this event. It is the same on every delivery of the same event, so it can be used to recognize a duplicate and to refer to one event, in logs or in a support request, without quoting patient data. Treat it as opaque |
eventTopic |
string | What the event is about. Always study today |
eventType |
string | What happened to the study. created: the study was processed for the first time. updated: the study was processed again |
eventTime |
string | When the change happened in Kastoria Health, as ISO 8601 UTC with milliseconds (2026-09-13T15:04:05.123Z). This is the time the study was processed, not the time the event was published and not the DICOM Study Date. It is the key events are ordered by (see Applying events in order) |
data |
object | The study's Ingestion Manifest as it stood after the change. See Ingestion Manifest fields |
Event types
Kastoria Health does not currently delete studies, so created and updated
are the only event types published. Two further types are reserved for when
study deletion is introduced:
| Event type | Status | Description |
|---|---|---|
created |
Published | The study was processed for the first time |
updated |
Published | The study was processed again |
removed |
Reserved | data is the study's last manifest before it was removed |
restored |
Reserved | — |
The definitions of the reserved types will be confirmed before either is first published.
Ingestion Manifest fields (data)
Each field is present in every event. A value that is absent from the DICOM
data is null, except studyModalities, which is always an array.
| Field | Type | Description |
|---|---|---|
imagingStudyResourceID |
string | The FHIR ImagingStudy id Kastoria Health assigns to the study. It is assigned when the study is first processed and does not change afterwards. It is the id an EHR passes as imagingStudy in a SMART on FHIR launch (see SMART on FHIR — smart-launch-api). There is no separate ImagingStudy resource behind it |
studyInstanceUID |
string | DICOM Study Instance UID (0020,000D). The unique identifier of the study. Events are applied in order per study, so this is the key the eventTime comparison is made within |
patientResourceID |
string or null |
The id of the FHIR Patient resource Kastoria Health holds for the study's patient. null when the study carries no Patient ID, since such a study has no Patient resource |
patientID |
string or null |
DICOM Patient ID (0010,0020) |
patientName |
string or null |
DICOM Patient's Name (0010,0010), as a person name |
patientBirthDate |
string or null |
DICOM Patient's Birth Date (0010,0030), as a date |
patientSex |
string or null |
DICOM Patient's Sex (0010,0040), as recorded, normally M, F or O |
accessionNumber |
string or null |
DICOM Accession Number (0008,0050) |
studyDate |
string or null |
DICOM Study Date (0008,0020), as a date |
studyTime |
string or null |
DICOM Study Time (0008,0030), as a time |
studyDescription |
string or null |
DICOM Study Description (0008,1030) |
studyModalities |
array of strings | The modalities in the study (e.g. CT, MR, XR), collected from the Modality (0008,0060) of each series, in upper case, each listed once, in no particular order. [] when no series records a modality |
referringPhysicianName |
string or null |
DICOM Referring Physician's Name (0008,0090), as a person name |
Patient and study values are taken from the study's DICOM instances as received.
DICOM value encoding
DICOM values are carried as stored, in their DICOM encoding, with leading and trailing spaces removed:
| VR | Format | Example |
|---|---|---|
| Date (DA) | YYYYMMDD |
19700101 |
| Time (TM) | HHMMSS.FFFFFF; as DICOM allows, trailing components may be omitted |
150405.123456, 150405, 1504 |
| Person name (PN) | DICOM alphabetic representation, components separated by carets: Family^Given^Middle^Prefix^Suffix; trailing empty components may be omitted |
Doe^Jane |
Queue
Kastoria Health publishes Notification Events to an Azure Storage Queue. Each queue message is exactly one event.
The event structure does not depend on the queue. Everything a consumer needs is in the JSON body, and nothing is carried in queue-specific message properties. If a different queue technology is agreed, the event is unchanged.
Queue properties
| Property | Value |
|---|---|
| Message expiry | Never. Every message is sent with no expiry (a time-to-live of -1), so an unread message stays on the queue until it is deleted. This overrides Azure's 7-day default |
| Message body encoding | text (the JSON as-is) or base64 (the JSON's UTF-8 bytes, base64-encoded), configurable per deployment. Every message on a queue uses the same encoding. An Azure Functions queue trigger expects base64 by default |
| Maximum message size | 64 KiB, as set by Azure. An event is a small fraction of that in either encoding |
| Delivery | At least once |
| Ordering | None. Order by eventTime (see Applying events in order) |
| Duplicate detection | None. Duplicates are expected, and the ordering rule makes them harmless |
| Dead-letter queue | None. See Messages that cannot be handled |
| Replay | None. A message deleted from the queue is gone. See Recovery |
Queue location
Kastoria Health is deployed in your Azure tenant, so the Storage Account and its queue are your resources. You create the queue and grant access to it. Kastoria Health does not create the queue. The queue can be in either of two places:
- In the Storage Account deployed with Kastoria Health. Create the queue in the Storage Account that is part of Kastoria Health's infrastructure.
- In your own Storage Account (optional). Create an Azure Storage Queue in a Storage Account of your choosing, and name that account in the Kastoria Health configuration.
Access
Access in both directions uses Entra ID role assignments on the queue. No SAS token is issued, by you or by Kastoria Health, so there is no queue credential to distribute, rotate or revoke; access is withdrawn by removing the role assignment.
| Party | Authenticates as | Role on the queue | Permits |
|---|---|---|---|
| Kastoria Health (publisher) | Its managed identity | Storage Queue Data Message Sender | Adding messages only — neither reading them nor changing the queue |
| Your consumer | An Entra ID principal of its own — a managed identity where it runs in Azure, a service principal where it does not | Storage Queue Data Message Processor | Reading, receiving and deleting messages — neither adding them nor changing the queue |
Kastoria Health authenticates to the queue the same way it reaches every other Azure Storage resource it uses, so no queue credential is configured or held anywhere. Once its identity holds the sender role, configure Kastoria Health with:
- the Storage Account name
- the queue's name
- the message encoding (
textorbase64)
All access is over HTTPS.
Every message contains protected health information (patient name, birth date and identifiers). Handle messages, logs and anything built from them accordingly.
Reading events
A consumer can use any Azure Storage Queue client: the Azure SDKs for .NET, Java, Python and JavaScript, an Azure Functions queue trigger, or the Queue service REST API directly. Each authenticates as the Entra ID principal described in Access:
| Client | Authentication |
|---|---|
| Azure SDKs | DefaultAzureCredential |
| Azure Functions queue trigger | Identity-based connection |
| Queue service REST API | Entra ID access token in the Authorization header |
The receive loop
- Receive a batch of messages (Get Messages, up to 32 at a time), with a visibility timeout long enough to handle the whole batch. While a message is invisible, no other receiver gets it.
- For each message:
- Decode the body according to the configured encoding, and parse it as JSON.
- Apply the event to your system using the rule in Applying events in order, and make that change durable.
- Delete the message (Delete Message, with the message id and the pop receipt from the receive).
- When the queue is empty, wait before receiving again, then repeat.
Delete a message only after its event is durably applied. If the consumer stops between applying and deleting, the message becomes visible again when its visibility timeout ends, and is delivered a second time. The ordering rule makes that harmless. The reverse order, deleting first, would lose the event if the consumer stopped before applying it.
With an Azure Functions queue trigger, the Functions runtime receives and
deletes for you. The message is deleted when the function completes
successfully. If Kastoria Health sends text, set the queue trigger's
messageEncoding to none in host.json.
Applying events in order
For each study, identified by data.studyInstanceUID:
Apply an event when its
eventTimeis equal to or later than theeventTimeof the event you hold for that study. Otherwise, discard it.
- Order by
eventTime, never by arrival. Events for one study can arrive out of order, and an older event can arrive after a newer one. - No separate duplicate check is needed. A duplicate carries the same
eventId,eventTimeanddataas the original, so applying it again changes nothing. UseeventIdto recognize one if you want to count or log duplicates. - Keep the
eventTimeyou applied with the study, so later events can be compared against it. - Make the comparison and the write one step if more than one consumer
instance writes to your system at once. Otherwise two instances can each
read the old
eventTimeand the older event can be written last.
Concurrent consumers
Several instances of the same consumer may receive from one queue concurrently to increase throughput. The rule above keeps the result correct.
Two different applications must not read the same queue. A Storage Queue gives each message to one receiver, so each application would see only part of the stream. Handle every purpose in one consumer.
Messages that cannot be handled
Messages never expire and the queue has no dead-letter queue, so a message the
consumer cannot handle returns to the queue after every visibility timeout,
indefinitely. Each message carries a dequeue count. Decide on a limit, and
when a message exceeds it, move the message somewhere it can be investigated
(a -poison queue or a table, for example) before deleting it. An Azure
Functions queue trigger does this automatically, moving the message to a
queue named <queue-name>-poison after five attempts by default.
When reporting a problem message to Kastoria Health, quote its eventId, not
its contents.
Recovery
A message deleted from the queue cannot be read again. If a consumer loses
events it had already deleted, an operator can re-publish every event from
a point in time they specify, from the Kastoria Health admin console.
Re-published events carry the same eventId, eventTime and data as the
originals, so the ordering rule applies them safely over whatever your system
already holds.
Examples
Create the queue and grant access
Create the queue, then grant the sender role to Kastoria Health's managed identity and the processor role to your consumer's principal, both scoped to the queue:
az storage queue create \
--account-name {storage-account} \
--name {queue} \
--auth-mode login
QUEUE_SCOPE="/subscriptions/{subscription-id}/resourceGroups/{resource-group}/providers/Microsoft.Storage/storageAccounts/{storage-account}/queueServices/default/queues/{queue}"
az role assignment create \
--assignee {kastoria-managed-identity-principal-id} \
--role "Storage Queue Data Message Sender" \
--scope "$QUEUE_SCOPE"
az role assignment create \
--assignee {consumer-principal-id} \
--role "Storage Queue Data Message Processor" \
--scope "$QUEUE_SCOPE"
Azure Functions trigger for text encoding
When Kastoria Health is configured to send text, disable the trigger's
default base64 decoding in host.json:
{
"version": "2.0",
"extensions": {
"queues": {
"messageEncoding": "none"
}
}
}
Applying an out-of-order event
Two events for the same study arrive in reverse order. The consumer holds no event for the study when the first one arrives:
| Arrival | eventType |
eventTime |
Held eventTime |
Action |
|---|---|---|---|---|
| 1 | updated |
2026-09-13T15:04:05.123Z |
— | Apply; hold 15:04:05.123Z |
| 2 | created |
2026-09-13T14:58:12.004Z |
15:04:05.123Z |
Discard — earlier than held |
| 3 | updated (duplicate of 1) |
2026-09-13T15:04:05.123Z |
15:04:05.123Z |
Apply — equal; no change |
See also
- STOW-RS — Store Instances — the endpoint studies are ingested through
- QIDO-RS — Search for Instances — note that a study announced by an event may not yet be searchable
- SMART on FHIR — smart-launch-api
— launches the viewer with the
imagingStudyResourceIDan event carries
Conformance
DICOM Conformance Statement for Kastoria Health DICOMweb Service
Introduction
This document describes the DICOM conformance statement for the Kastoria Health DICOMweb Service. A DICOM Conformance Statement is a technical document that describes how a device or software implements the DICOM standard.
The Kastoria Health DICOMweb Service supports a subset of the DICOMweb Standard for medical imaging data management. Support includes:
- Studies Service
- Store (STOW-RS)
- Retrieve (WADO-RS)
- Search (QIDO-RS)
The service exposes a REST API following the DICOMweb standard (DICOM PS3.18).
Service Base URL
The service base URL follows the pattern:
https://{host}/
A GET /health endpoint is provided for liveness checks, and GET / returns basic server
information.
Authorization
The Retrieve (WADO-RS) and Search (QIDO-RS) transactions support study-scoped access with viewer JSON Web Tokens (JWTs). Authorization is off by default and is enabled per deployment. When it is enabled:
- Every WADO-RS and QIDO-RS request must carry
Authorization: Bearer <jwt>. A missing, malformed, invalid or expired token is refused with401 (Unauthorized). - The token's
studyInstanceUIDsclaim is the authorization scope. A request whose path names a Study Instance UID outside that scope is refused with403 (Forbidden). - Study search (
GET /studies) returns only studies within the scope; a token whose scope is empty receives an empty result. - A scope containing
*grants every study.
Store (STOW-RS) requests are not subject to viewer-token authorization.
See JWT Authorization for the token format, claims and verification rules.
Studies Service
The Studies Service allows users to store, retrieve, and search for DICOM Studies, Series, and Instances.
Store (STOW-RS)
This transaction uses the POST method to store representations of studies, series, and instances contained in the request payload.
| Method | Path | Description |
|---|---|---|
| POST | /studies | Store instances |
| POST | /studies/{study} | Store instances for a specific study |
Parameter study corresponds to the DICOM attribute StudyInstanceUID. If specified, any instance
that doesn't belong to the provided study is reported as a failure with failure reason 272 (PS3.18
§6.6.1.2).
Responses are always returned as:
application/dicom+json
The following Content-Type headers are supported:
multipart/related; type="application/dicom"application/dicom
Ingest-Time Transcoding
Unlike a pass-through store, the service may transcode pixel data at ingest time according to rules
evaluated against the instance's metadata (modality, transfer syntax, frame parameters such as bits
allocated/stored, rows, columns, and photometric interpretation — see Transfer Syntax
Support). By default, instances of modalities CR, CT, DX, MG, and MR —
and losslessly-encoded NM, OT, PT, RF, SM, and US instances — are transcoded to High-Throughput JPEG
2000 with RPCL Options (Lossless Only, 1.2.840.10008.1.2.4.202). All other DICOM attributes are
stored as provided.
Two transfer syntaxes are normalized before any rule is evaluated: instances arriving as Deflated
Explicit VR Little Endian (1.2.840.10008.1.2.1.99) or Explicit VR Big Endian
(1.2.840.10008.1.2.2) are re-encoded losslessly to Explicit VR Little Endian, and it is that
re-encoding the service stores — these two syntaxes are never a stored syntax. All data element
values, including pixel data, are preserved, though the bytes are re-serialized as Explicit VR
Little Endian: a big-endian instance comes back with every multi-byte value in little-endian byte
order, and an out-of-order dataset comes back re-sorted into ascending tag order. The file meta
group is rewritten by the encoder and carries its implementation UID rather than the sender's. An
instance whose deflated stream does not decode is rejected like any other unparseable input.
Ingest-time transcoding can be disabled per request with the header:
| Header | Accepted Values | Description |
|---|---|---|
x-kastoria-disable-transcode |
true |
Store instances in their original transfer syntax without transcoding (Deflated and Big Endian inputs are still normalized to Explicit VR Little Endian) |
Store Required Attributes
Each DICOM file must be parseable as a DICOM Part 10 file, and the following DICOM elements are required to be present:
StudyInstanceUID(0020,000D)SeriesInstanceUID(0020,000E)SOPInstanceUID(0008,0018)TransferSyntaxUID(0002,0010)
Note:
SOPClassUID(0008,0016) is extracted on a best-effort basis; instances lacking it are still accepted- No UID format/length validation is performed beyond presence checks
- Files that fail parsing or are zero-length are reported in the
FailedSOPSequencewith failure reason272
Store Response Status Codes
| Code | Description |
|---|---|
200 (OK) |
All the SOP instances in the request were stored successfully |
202 (Accepted) |
Some instances were stored successfully, others failed. Details are in the response body |
400 (Bad Request) |
The provided Content-Type is neither multipart/related nor application/dicom |
409 (Conflict) |
None of the instances in the store transaction request were stored |
500 (Internal Server Error) |
The server encountered an unexpected error, including a multipart/related Content-Type that names no boundary |
Store Response Payload
The response payload populates a DICOM dataset with the following elements:
| Tag | Name | Description |
|---|---|---|
| (0008,1190) | RetrieveURL |
The Retrieve URL of the study if StudyInstanceUID was provided and at least one instance was successfully stored |
| (0008,1198) | FailedSOPSequence |
The sequence of instances that failed to store |
| (0008,1199) | ReferencedSOPSequence |
The sequence of stored instances |
Each dataset in the FailedSOPSequence has the following elements:
| Tag | Name | Description |
|---|---|---|
| (0008,1150) | ReferencedSOPClassUID |
The SOP class UID of the instance that failed to store |
| (0008,1155) | ReferencedSOPInstanceUID |
The SOP instance UID of the instance that failed to store |
| (0008,1197) | FailureReason |
The reason code why this instance failed to store |
Each dataset in the ReferencedSOPSequence has the following elements:
| Tag | Name | Description |
|---|---|---|
| (0008,1150) | ReferencedSOPClassUID |
The SOP class UID of the instance that was stored (omitted when unavailable) |
| (0008,1155) | ReferencedSOPInstanceUID |
The SOP instance UID of the instance that was stored |
| (0008,1190) | RetrieveURL |
The retrieve URL of this instance on the DICOM server |
Note on Retrieve URLs: For POST /studies (without a study UID), the top-level study
RetrieveURL is only included when all stored instances belong to a single study.
Retrieve URL tags are omitted entirely when the base URL cannot be determined. The
authoritative base URL for retrieval from the DICOMweb service is configured in the service
deployment. As a RetrieveURL is dereferenced by whoever receives it, it is never built from an
unverified request header. If the base URL is not configured for a deployment, store responses from
that service carry no (0008,1190) at either level. The store itself still succeeds and the response
is otherwise unchanged — the instances are stored and listed in the ReferencedSOPSequence, without
a URL to fetch them back by.
Store Failure Reason Codes
| Code | Description |
|---|---|
272 (0x0110) |
Processing failure — used for all store failures, including parse failures, zero-length parts, storage errors, and instances whose StudyInstanceUID does not match the study UID stated in the request path |
Retrieve (WADO-RS)
This transaction offers support for retrieving stored studies, series, instances, frames, metadata, rendered images, and thumbnails by reference.
| Method | Path | Description |
|---|---|---|
| GET | /studies/{study} | Retrieve all instances within a study |
| GET | /studies/{study}/metadata | Retrieve metadata for all instances within a study |
| GET | /studies/{study}/series/{series} | Retrieve all instances within a series |
| GET | /studies/{study}/series/{series}/metadata | Retrieve metadata for all instances within a series |
| GET | /studies/{study}/series/{series}/instances/{instance} | Retrieve a single instance |
| GET | /studies/{study}/series/{series}/instances/{instance}/metadata | Retrieve metadata for a single instance |
| GET | /studies/{study}/series/{series}/instances/{instance}/frames/{frames} | Retrieve one or many frames from a single instance (comma-separated frame numbers, 1-indexed) |
| GET | /studies/{study}/series/{series}/instances/{instance}/rendered | Retrieve an instance rendered into an image format (renders frame 0) |
| GET | /studies/{study}/series/{series}/instances/{instance}/frames/{frames}/rendered | Retrieve frame(s) rendered into an image format |
| GET | /studies/{study}/series/{series}/instances/{instance}/thumbnail | Retrieve a thumbnail of an instance (renders frame 0) |
| GET | /studies/{study}/series/{series}/instances/{instance}/frames/{frames}/thumbnail | Retrieve thumbnail(s) of frame(s) |
Retrieve Instances Within Study or Series
The following Accept headers are supported for retrieving instances:
multipart/related; type="application/dicom"application/dicomapplication/**/*(defaults tomultipart/related; type="application/dicom")
The transfer-syntax Accept-header parameter (PS3.18 §8.7.3.5.2) is supported. When a specific
transfer syntax is requested and differs from the stored one, each instance is transcoded on the fly
before being returned. transfer-syntax=* (or omitting the parameter) returns instances in their
stored transfer syntax (which may be the result of ingest-time transcoding — see
Store). Each part's Content-Type carries a transfer-syntax parameter
identifying the actual returned syntax (PS3.18 §8.7.9). An unsupported requested transfer syntax
results in 406 Not Acceptable.
Retrieve an Instance
The following Accept headers are supported for retrieving a specific instance:
multipart/related; type="application/dicom"application/dicomapplication/**/*(defaults tomultipart/related; type="application/dicom")
The transfer-syntax Accept-header parameter is supported as described above — the instance is
transcoded when the requested syntax differs from the stored one.
Retrieve Frames
The following Accept headers are supported for retrieving frames:
multipart/related- returnsmultipart/related; type="<frame media type>"with appropriate media type based on the instance's transfer syntaxapplication/octet-streamapplication/**/*
The frame media type is derived from the returned transfer syntax:
application/octet-stream- Uncompressedimage/dicom-rle- RLE Losslessimage/jpeg- JPEG Baseline or JPEG Losslessimage/jls- JPEG-LSimage/jp2- JPEG 2000image/jxl- JPEG XLimage/jphc- High-Throughput JPEG 2000
A single-part response carries the frame media type, not application/octet-stream. A request
for one frame under Accept: */* against a JPEG-encoded instance is answered Content-Type:
image/jpeg; only an instance in an uncompressed transfer syntax is answered
application/octet-stream. There is no single-part media type this service falls back to — the
bytes are the stored frame, and the header names what they actually are.
A request naming more than one frame is always answered as multipart/related, whatever the
Accept header allows, because separate frames need separate body parts (PS3.18 §10.4.3).
The Accept header is checked against every frame media type this service can produce, not
against the one this instance will produce. A request with Accept: application/octet-stream for
a JPEG-encoded frame is therefore answered 200 with Content-Type: image/jpeg rather than 406.
A client that can only decode one encoding should name the transfer syntax it wants with the
transfer-syntax Accept-header parameter, which does govern what comes back. That parameter is
read from a multipart/related, application/dicom, application/* or */* media range only —
carried on a concrete image media type such as image/jpeg, it is ignored.
The transfer-syntax Accept-header parameter is supported. When a specific transfer syntax is
requested and differs from the stored one, frames are transcoded on the fly and the frame media type
reflects the target syntax. transfer-syntax=* (or omitting the parameter) returns frames in their
stored format. An unsupported requested transfer syntax results in 406 Not Acceptable.
Retrieve Metadata (for Study, Series, or Instance)
The following Accept header is supported for retrieving metadata:
application/dicom+json, or a wildcard that covers it (application/*,*/*, or noAcceptheader at all)
Metadata is returned in DICOM JSON format as defined in PS3.18 Annex F.
Compression: Metadata responses are served gzip-compressed when the client sends
Accept-Encoding: gzip.
Note: Bulk pixel data (PixelData, 7FE0,0010) is never included in metadata responses; retrieve
it via the frames or instance endpoints.
Retrieve Rendered Image (for Instance or Frame)
The following Accept headers are supported for retrieving a rendered image:
image/pngimage/jpegimage/bmpimage/*(defaults to PNG)*/*(defaults to PNG)multipart/relatedorapplication/octet-stream(defaults to PNG) — accepted so a client that names only a container gets a rendered image rather than a406
The service renders the specified instance or frame(s) into the requested image format. For instance
endpoints, frame 0 is rendered. Multiple frames are returned as multipart/related with individual
image parts.
Supported Query Parameters:
| Parameter | Format | Description |
|---|---|---|
viewport |
vw,vh[,sx,sy,sw,sh] |
Scaling dimensions (vw, vh: required positive integers, with vw×vh at most 16,777,216). Optional crop, all four values or none: sx, sy non-negative integers, sw, sh positive integers; the source region is extracted before scaling |
window |
center,width,function |
Windowing parameters. center and width must be numbers; function must be linear or sigmoid |
quality |
1-100 |
JPEG quality (integer 1-100, applies only to JPEG output) |
Priority: Image formats are selected in priority order: PNG > JPEG > BMP
Retrieve Thumbnail (for Instance or Frame)
The following Accept headers are supported for retrieving a thumbnail:
image/jpeg(default per DICOM spec)image/pngimage/bmpimage/*(defaults to JPEG)*/*(defaults to JPEG)multipart/relatedorapplication/octet-stream(defaults to JPEG) — accepted so a client that names only a container gets a thumbnail rather than a406
The service generates a thumbnail of the specified instance or frame(s). For instance endpoints,
frame 0 is rendered. Multiple frames are returned as multipart/related with individual image
parts.
Supported Query Parameters:
| Parameter | Format | Description |
|---|---|---|
viewport |
vw,vh |
Thumbnail dimensions (width and height are required positive integers, with vw×vh at most 1,048,576). Defaults to 128,128 |
Priority: Image formats are selected in priority order: JPEG > PNG > BMP
Retrieve Response Status Codes
| Code | Description |
|---|---|
200 (OK) |
All requested data was retrieved successfully |
400 (Bad Request) |
Invalid query parameters (e.g., malformed viewport, window, or quality value, or a non-integer frame number), or a /rendered or /thumbnail request naming more frames than the service is configured to render at once |
401 (Unauthorized) |
Authorization is enabled and the request carries no valid bearer token; see Authorization |
403 (Forbidden) |
Authorization is enabled and the requested study is not in the token's scope; see Authorization |
404 (Not Found) |
The specified DICOM resource could not be found, or the instance does not contain pixel data for rendered/thumbnail requests |
406 (Not Acceptable) |
The specified Accept header is not supported, or a requested transfer-syntax cannot be transcoded to |
500 (Internal Server Error) |
The server encountered an unexpected error (if a mid-stream error occurs after headers are sent, the connection is torn down instead) |
Search (QIDO-RS)
Query based on ID for DICOM Objects (QIDO) enables searching for studies, series and instances by attributes.
| Method | Path | Description |
|---|---|---|
| GET | /studies | Search for studies |
| GET | /studies/{study}/series | Search for series in a study |
| GET | /studies/{study}/instances | Search for instances in a study |
| GET | /studies/{study}/series/{series}/instances | Search for instances in a series |
The following Accept header is supported for searching:
application/dicom+json, or a wildcard that covers it (application/*,*/*, or noAcceptheader at all)
Index Requirements
Search queries are served from search indexes that are maintained automatically by a background study-processing stage after instances are stored.
Important: Newly stored studies become searchable once this processing has completed. Queries against studies that have not yet been processed return an empty array.
Supported Search Parameters (Study-Level)
The following query parameters are supported for study-level searches:
| Parameter | Support | Description |
|---|---|---|
StudyInstanceUID |
Direct lookup | Unique study identifier |
PatientID |
Exact match, case-insensitive | Patient identifier |
StudyDate |
Single date or range | Study date in YYYYMMDD format. Supports single date (20240101) or range (20240101-20240131, 20240101-, -20240131) |
PatientName |
Leading components, case-insensitive | Patient's name in DICOM PN form. The value names leading components and each matches whole: DOE matches DOE^JOHN, DOE^JOHN matches DOE^JOHN^Q but not DOE^JOHNSON. Matched against the alphabetic component group |
StudyTime |
Single time or range | Study time in DICOM TM format, HH[MM[SS[.FFFFFF]]]. Supports single time (143000) or range (080000-170000, 080000-, -170000) |
AccessionNumber |
Exact match, case-insensitive | Accession number |
StudyDescription |
Exact match, case-insensitive | Study description |
ModalitiesInStudy |
List; matches any one modality, ignoring case | Modality (e.g. CT, MR). A list separated by \ or , is an IN clause — the study matches when any of its modalities equals any listed value |
limit |
Supported (default 100, maximum 1000) | Maximum number of matches returned in one response |
offset |
Supported (default 0, maximum 1000) | Number of matches skipped before the first returned one. A value above the maximum is rejected with 400, not capped |
Search Matching
The service supports the following matching types:
| Search Type | Supported Attributes | Example |
|---|---|---|
| Range Query | StudyDate, StudyTime |
StudyDate=20240101-20240131 - Inclusive range. Either bound may be omitted (e.g., StudyDate=20240101- or StudyDate=-20240131) |
| Exact Match | StudyInstanceUID, PatientID, AccessionNumber, StudyDescription, ModalitiesInStudy |
PatientID=PATIENT001 |
| Leading Components | PatientName |
PatientName=DOE matches DOE^JOHN — the value names leading PN components and each matches whole, so this is not an exact match on the full name |
Note:
- A zero-length value is universal matching (PS3.4 §C.2.2.2.1):
PatientName=places no constraint and every study matches. It is neither an error nor an ignored parameter, so it carries no Warning header field — a universal matcher selects everything by definition - Matching ignores case for every attribute except
StudyInstanceUID, which is a UID:aa-1234andAA-1234are one identifier, andchrisandCHRISare one person StudyTimevalues are compared by the instant they denote, so a query and a stored value written to different precision (090000and090000.000000) match- The upper bound of a
StudyTimerange runs to the end of the unit it names, matching the way aStudyDateupper bound runs to the end of its day.-235959includes a study acquired at235959.500, and-0900includes one at090030. An explicit fraction names an instant instead, so-090030.000ends exactly there - Each query parameter must be supplied at most once. A repeated parameter —
PatientID=A&PatientID=B— is rejected with400 Bad Requestnaming the parameter - A
StudyTimenaming the whole day —000000-235959,000000-,-235959, or a bare-— narrows nothing and is treated as absent rather than searched for
Attribute ID
Query parameter keys may be encoded as either of the following (per PS3.18 §8.3.4.1):
| Value | Example |
|---|---|
{group}{element} |
0020000D |
{dicomKeyword} |
StudyInstanceUID |
Response Pagination
Both search transactions implement response pagination per PS3.18 §8.3.4.4.
| Parameter | Default | Behavior |
|---|---|---|
offset |
0 | Number of matches skipped before the first returned match; an explicit value above 1000 is rejected with 400 |
limit |
100 | Maximum matches returned in one response; an explicit value above 1000 is capped |
maxResults (PS3.18 §8.3.4.4.1) is 1000 — the ceiling on an explicit limit, so no single
response returns more than 1000 matches. A request that supplies no limit receives 100.
When matches remain beyond those returned, the response carries a Warning header field:
Warning: 299 {service}: There are more additional results that can be requested
The header announces that a further page can be requested. It does not carry a count of the matches not returned.
Search Depth
A search that combines two or more matching attributes is limited in how far back it reaches, but only where it has to be. Naming anything selective — a patient identifier, an accession number — settles the search almost at once, and it is then answered in full however old the matches are. The limit is reached only when every attribute named is a broad one.
Where a search is limited, the response is 200 (OK) and carries a Warning header field naming the
earliest study date covered:
Warning: 299 {service}: Searched studies from 20241216 onward; earlier matches may exist
Results from that date onward are complete. Nothing is claimed about studies before it.
A PatientName search names no date. A PatientName filter matches leading components, so it
is answered from a range covering every name built on them — and inside that range entries are
ordered by name before date, so the search reaches the second patient only after exhausting the
first. Where such a search is limited, the warning states the limit without dating it:
Warning: 299 {service}: The search was limited before every matching study was examined; further matches may exist
Both spellings mean the same thing to a caller: the result set is not known to be complete. Only the first tells you from when it is.
A search on a single matching attribute is not limited at all, and neither is a search on none. Both are answered in full — a search for a modality last acquired three years ago returns it.
An empty result set carries the warning too, when the search was limited. 200 (OK) with an
empty array and no Warning header means no study matches. 200 (OK) with an empty array and this
warning means no study matches within the range searched. These are different answers, and they are
distinguished by the header rather than by the payload.
An explicit StudyDate narrows the search but does not remove the limit. It is the most
effective thing a caller can supply: it bounds every attribute at once, so a search that names a
date is normally answered in full and carries no warning whatever attributes accompany it.
A caller needing a longer history for a broad combination of attributes should page through it by date, a range at a time.
Unsupported Query Parameters
A query parameter this service does not match on is accepted, ignored, and announced. The
response is 200 (OK) and carries a Warning header field naming every parameter that was
ignored:
Warning: 299 {service}: The following query parameters are not supported and were ignored: PatientSex\StudyID
This applies to both search transactions, and to any parameter other than that level's matching
attributes listed above and the limit / offset controls — including an attribute the service
returns but cannot match on, and a parameter it does not recognize at all. The result set is
therefore wider than the request asked for: the ignored parameter narrowed nothing.
A 400 is not used here. PS3.18 reserves it for a query the service cannot parse, and PS3.4
§C.2.2.1.3 permits an Optional Key to be returned without being matched on — so a well-formed
query the service can only partly honor is answered, with the shortfall stated.
Parameter names are separated by \, the DICOM multi-valued delimiter, rather than by a comma.
Warning is a comma-delimited list field, so a comma inside one warn-text would make two warnings
impossible to tell apart.
A name this service cannot repeat back is reported as (unprintable). DICOM keywords and
Attribute IDs are alphanumeric, so a parameter name outside that shape is not one this service
defines, and echoing it would place caller-supplied text — a line break included — into a header
field. The substitution keeps the count honest: the caller is still told how many of its parameters
were ignored, even where one of them cannot be named. The parentheses are what make the token
impossible to mistake for an Attribute ID.
Warning: 299 {service}: The following query parameters are not supported and were ignored: PatientSex\(unprintable)
Both warnings are sent when both apply — a query naming an ignored attribute can still overflow its page — as two warn-values, the query's shortcoming before the response's:
Warning: 299 {service}: The following query parameters are not supported and were ignored: PatientSex
Warning: 299 {service}: There are more additional results that can be requested
Both parameters must be non-negative integers. A malformed value is rejected with 400 Bad
Request rather than ignored — silently dropping a limit would return the full result set to a
caller that asked for one page.
offset may not exceed 1000 — the same ceiling as limit, so a request may skip no more than
it may retrieve — and a larger one is rejected with 400 Bad Request rather than capped. Paging is
offset-based with no cursor, so reaching a match means loading every match in front of it, and a
skipped match costs the same storage reads as a returned one. A caller needing to go deeper should
narrow the query rather than page further into an unnarrowed one.
limit is capped rather than rejected because a capped page is still a prefix of the page
requested, and the Warning header says more remains — whereas a capped offset would silently
answer with a different page than the one asked for.
Results are ordered by study date and time, most recent first. One exception:
- A search on
PatientNamethat names fewer components than the stored name —SMITHmatchingSMITH^JOHN— is ordered most-recent-first within each matching patient, but is not ordered by date across several patients sharing those leading components.
Notes for Client Implementers
Do not treat 200 (OK) with an empty array as definitive without checking for a Warning header
field. A search limited to a window returns the same empty payload whether nothing matches at all
or nothing matches recently. The header is the only distinction.
Treat a 299 warning as information, not an error. It accompanies a successful response and
does not indicate a failed or degraded search. A response may carry more than one — an ignored
parameter, a limited window, and further pages available are independent conditions and can all
apply at once.
To search a longer history, page by date rather than widening the range. Take the date the
warning names, and issue the next request with a StudyDate range ending the day before it. Repeat
until a response arrives without the warning, or until far enough back to stop. Each request is
bounded, and together they cover the history without any single request being expensive.
Supply a StudyDate whenever one is known. It bounds every attribute at once, which is both
faster and the surest way to receive a complete answer rather than a limited one.
Prefer StudyInstanceUID when it is known. It is answered as a direct lookup, without reading
any index.
Supported Search Parameters (Series-Level)
The following query parameters are supported for series-level searches within a study:
| Parameter | Support | Description |
|---|---|---|
Modality |
List; matches any one, ignoring case | Filter by modality. A list separated by \ or , is an IN clause |
SeriesInstanceUID |
Exact match | Filter by series UID (a UID has no case to ignore) |
SeriesNumber |
Exact match | Filter by series number |
limit |
Supported (default 100, maximum 1000) | Maximum number of matches returned in one response |
offset |
Supported (default 0, maximum 1000) | Number of matches skipped before the first returned. A value above the maximum is rejected with 400, not capped |
Supported Search Parameters (Instance-Level)
The following query parameters are supported for instance-level searches, both within a series and within a whole study:
| Parameter | Support | Description |
|---|---|---|
SOPInstanceUID |
Exact match | Filter by SOP Instance UID (a UID has no case to ignore) |
InstanceNumber |
Exact match | Filter by instance number |
SeriesInstanceUID |
Exact match | Filter by series UID. On the study-level endpoint this narrows the search to one series; on the series-level endpoint the series is already fixed by the path, so asking for a different one returns an empty array |
limit |
Supported (default 100, maximum 1000) | Maximum number of matches returned in one response |
offset |
Supported (default 0, maximum 1000) | Number of matches skipped before the first returned. A value above the maximum is rejected with 400, not capped |
Instance-level searches do not use the search indexes, but they read data built by the same study-processing stage described under Index Requirements. As with series-level searches, a study that has not yet been processed returns an empty array.
Search Response
The response is an array of DICOM datasets in DICOM JSON format. The following attributes are returned by default for study-level searches:
Default Study Tags:
| Tag | Attribute Name |
|---|---|
| (0020,000D) | StudyInstanceUID |
| (0010,0020) | PatientID |
| (0010,0010) | PatientName |
| (0010,0030) | PatientBirthDate |
| (0010,0040) | PatientSex |
| (0020,0010) | StudyID |
| (0008,0020) | StudyDate |
| (0008,0030) | StudyTime |
| (0008,0050) | AccessionNumber |
| (0008,0061) | ModalitiesInStudy |
| (0008,1030) | StudyDescription |
| (0020,1206) | NumberOfStudyRelatedSeries |
| (0020,1208) | NumberOfStudyRelatedInstances |
Default Series Tags:
| Tag | Attribute Name |
|---|---|
| (0020,000D) | StudyInstanceUID |
| (0020,000E) | SeriesInstanceUID |
| (0020,0011) | SeriesNumber |
| (0008,0005) | SpecificCharacterSet |
| (0008,0060) | Modality |
| (0008,0021) | SeriesDate |
| (0008,0031) | SeriesTime |
| (0008,103E) | SeriesDescription |
| (0008,0070) | Manufacturer |
| (0008,0080) | InstitutionName |
| (0008,0090) | ReferringPhysicianName |
| (0008,1090) | ManufacturerModelName |
| (0018,0015) | BodyPartExamined |
| (0018,1030) | ProtocolName |
| (0020,1209) | NumberOfSeriesRelatedInstances |
Default Instance Tags:
The minimum set of PS3.18 Table 10.6.3-5, returned by both instance-level searches:
| Tag | Attribute Name |
|---|---|
| (0008,0016) | SOPClassUID |
| (0008,0018) | SOPInstanceUID |
| (0020,000D) | StudyInstanceUID |
| (0020,000E) | SeriesInstanceUID |
| (0020,0013) | InstanceNumber |
| (0028,0008) | NumberOfFrames |
Note: The service does not currently support the includefield parameter to return additional
attributes beyond the defaults.
Search Response Status Codes
| Code | Description |
|---|---|
200 (OK) |
The response payload contains all matching resources (an empty array when there are no matches) |
400 (Bad Request) |
The query cannot be honored as written: a range that cannot match (a StudyDate range whose end precedes its start), a limit or offset that is not a non-negative integer, an offset above 1000, or a query parameter supplied more than once |
401 (Unauthorized) |
Authorization is enabled and the request carries no valid bearer token; see Authorization |
403 (Forbidden) |
Authorization is enabled and a request for a study by UID, or for anything beneath it (series, instances, frames, metadata, etc.), names a Study Instance UID that is not in the token's scope; see Authorization |
406 (Not Acceptable) |
The specified Accept header does not allow application/dicom+json |
500 (Internal Server Error) |
The server encountered an unexpected error |
Search Notes
- An empty result set is returned with
200 (OK)and an empty JSON array —204 (No Content)is not used - A
200 (OK)always means the search ran to completion. A search that fails part-way through returns500, and never a partial or empty array with200— a client must be able to distinguish "no studies match" from "the search could not be completed". A query the service cannot satisfy because of how it is written returns400, so a caller can tell its own mistake from the server's. - A
200 (OK)does not by itself mean every parameter was applied. Two things narrow what the payload holds relative to what was asked, and both are stated in a Warning header field rather than left for the client to infer: - A parameter this service does not match on is ignored, so the result set is wider than the request — see Unsupported Query Parameters.
- Matches beyond
limitare not returned, so the result set is narrower than the whole match set — see themaxResultsnote above.
A response carrying no Warning header field is complete in every sense: every parameter was applied, no match was left behind, and no limit was placed on how far back the search reached.
Technical Specifications
Transfer Syntax Support
The following transfer syntaxes are recognized for ingest-time transcoding decisions. Transfer syntaxes not in this list are stored as-is (no transcoding is attempted).
Uncompressed:
- 1.2.840.10008.1.2 (Implicit VR Little Endian)
- 1.2.840.10008.1.2.1 (Explicit VR Little Endian)
- 1.2.840.10008.1.2.2 (Explicit VR Big Endian) — accepted on ingest, stored as Explicit VR Little Endian (see Ingest-Time Transcoding); available as a retrieve target
- 1.2.840.10008.1.2.1.99 (Deflated Explicit VR Little Endian) — accepted on ingest, stored as Explicit VR Little Endian (see Ingest-Time Transcoding); not available as a retrieve target
Compressed:
- 1.2.840.10008.1.2.5 (RLE Lossless)
- 1.2.840.10008.1.2.4.50 (JPEG Baseline Process 1)
- 1.2.840.10008.1.2.4.57 (JPEG Lossless, Non-Hierarchical)
- 1.2.840.10008.1.2.4.70 (JPEG Lossless, Non-Hierarchical, First-Order Prediction)
- 1.2.840.10008.1.2.4.80 (JPEG-LS Lossless)
- 1.2.840.10008.1.2.4.81 (JPEG-LS Lossy)
- 1.2.840.10008.1.2.4.90 (JPEG 2000 Lossless Only)
- 1.2.840.10008.1.2.4.91 (JPEG 2000)
- 1.2.840.10008.1.2.4.110 (JPEG XL Lossless)
- 1.2.840.10008.1.2.4.112 (JPEG XL)
- 1.2.840.10008.1.2.4.201 (High-Throughput JPEG 2000 Lossless Only)
- 1.2.840.10008.1.2.4.202 (High-Throughput JPEG 2000 with RPCL Options Lossless Only)
- 1.2.840.10008.1.2.4.203 (High-Throughput JPEG 2000)
Ingest Transcoding Rules
At store time the first matching rule determines the target transfer syntax; if the instance is already in the target syntax (or no rule matches), it is stored unchanged. Rules are evaluated against the post-normalization transfer syntax, so an instance sent as Deflated or Big Endian Explicit VR is evaluated as Explicit VR Little Endian:
| Modality | Condition | Target Transfer Syntax |
|---|---|---|
| CR, CT, DX, MG, MR | Always | 1.2.840.10008.1.2.4.202 (HTJ2K RPCL Lossless Only) |
| NM, OT, PT, RF, SM, US | Source transfer syntax is lossless | 1.2.840.10008.1.2.4.202 (HTJ2K RPCL Lossless Only) |
Transcoding can be disabled per request with the x-kastoria-disable-transcode: true header.
Retrieve-Time Transcoding
WADO-RS retrieve endpoints (study, series, instance, and frames) honor the transfer-syntax
Accept-header parameter. When the requested transfer syntax differs from the stored one, the service
transcodes on the fly using the same transfer syntax support table above. Deflated Explicit VR
Little Endian is not offered as a retrieve target. Transcoding between any other pair of supported
transfer syntaxes is available, Explicit VR Big Endian included; requesting an unsupported transfer
syntax (or one for which no codec is available) results in 406 Not Acceptable.
Character Set Support
The service supports all DICOM-defined character sets.
Limitations and Known Issues
Current Limitations
The following optional features are not supported:
-
Wildcard Matching: The optional wildcard matching capability is not supported, and wildcard characters (
*,?) are not treated as wildcards in any search parameter. For attributes with a PN VR, PS3.4 §C.2.2.2.4 makes wildcard matching implementation dependent and requires the position to be stated here. -
QIDO-RS includefield: The optional
includefieldparameter (PS3.18 §8.3.4.3) to return additional attributes beyond defaults is not currently supported. -
Rendered Image Viewport Cropping: Only the numeric
sx,sy,sw,shform (PS3.18 §8.3.5.1.3) is supported. Empty crop sub-values (which the standard defines as defaulting to 0 or the image edge) and negativesw/sh(which the standard defines as a horizontal/vertical flip) are rejected with400rather than honored. -
ETag/Cache Validation: ETag headers and If-None-Match cache validation (optional per PS3.18 §8.3.3) are not supported for metadata endpoints.
-
Fuzzy Matching: Fuzzy matching (optional per PS3.18 §10.6.3.2) is not supported in any search parameters.
References
DICOM® is the registered trademark of the National Electrical Manufacturers Association for its standards publications relating to digital communications of medical information.
Release Notes
Kastoria Health v0.10.0 Release Notes
About this release
Kastoria Health v0.10.0 builds on v0.9.1 with broader DICOMweb support, faster and more memory-efficient image retrieval, and better fidelity for the data customers send us.
These notes summarize what has changed since v0.9.1 and the behavior to be aware of when upgrading. The full technical detail of DICOMweb support is in the DICOM Conformance Statement.
Audience
These notes are intended for CIOs, CTOs, security reviewers, enterprise architects, and senior developers evaluating, deploying, or integrating Kastoria Health.
Highlights
- Search for individual images. Viewers and integrations can now search for the instances within a study or series, completing QIDO-RS search at the study, series and instance levels.
- Zoom into a region of an image. Rendered images can now be cropped to a region of interest on the server, rather than the whole image being sent and cropped by the client.
- Much faster retrieval of large images. Large instances begin downloading in milliseconds rather than seconds, and the service's memory use no longer grows with the size of the image being retrieved.
- More legacy data accepted. Images from older archives that use Big Endian or Deflated encodings can now be stored and retrieved.
- Complete images returned. Data that appears after the pixel data in an image file is now kept and returned on retrieval.
What is new
DICOMweb
- Instance-level search. Two new QIDO-RS searches return the instances in a study, or in a series, filtered by SOP Instance UID, Instance Number or Series Instance UID. Each result carries the standard minimum set of instance attributes, so a viewer can list a series' images without retrieving them.
- Rendered image cropping. The rendered image endpoints now honor the crop coordinates of the
viewportparameter, returning just the requested region of the image, scaled to the requested size. - Big Endian and Deflated images. Images sent in the Explicit VR Big Endian or Deflated Explicit VR Little Endian encodings are converted, losslessly, to the standard Explicit VR Little Endian encoding when they are stored. Previously these images were accepted but could not be retrieved correctly.
- Data after the pixel data is preserved. Some images carry data elements after the pixel data. These were previously dropped on retrieval; they are now stored and returned with the image.
Performance
- Streaming retrieval. Instances, series and studies are now streamed to the client as they are read, instead of being assembled in memory first. For a 1 GB instance, the first bytes arrive in about 15 milliseconds instead of 1.5 seconds, and peak server memory falls from over 5 GB to under 400 MB.
- Streaming conversion. When a client asks for compressed images (JPEG-LS, JPEG Lossless, JPEG 2000 or HTJ2K) in uncompressed form, they are now converted and sent one frame at a time. Peak memory for these requests is up to 20 times lower, and the first bytes arrive in milliseconds rather than seconds.
Reliability
- No silently missed updates. Study processing and Event Notification learn of new data from the change history Kastoria Core keeps. If a change is saved but its entry in that history cannot be recorded, the service now retries, and reports a failure if it cannot recover, rather than reporting success. This prevents a stored change from being missed by processing and notifications.
Security
- Fix for CVE-2026-101916. This release resolves CVE-2026-101916, a High severity certificate verification vulnerability in the gRPC library used to send telemetry. It affected the kastoria-health, kastoria-studyprocessor, kastoria-eventnotification, kastoria-consoleapi and kastoria-smart-launch-api images. The library is used only to forward metrics to the local OpenTelemetry collector, which is already secured by mutual TLS within the Azure Container Apps environment, so we do not consider the vulnerability exploitable in Kastoria Health deployments.
- Fix for CVE-2026-93990. This release resolves CVE-2026-93990, a High severity vulnerability in the Expat XML parsing library, which is installed with the NGINX web server. Expat versions before 2.8.5 accept malformed UTF-16 encoded XML. It affected the kastoria-proxy, kastoria-proxy-mtls, kastoria-consoleui and kastoria-ohif images. These images use NGINX only to route requests and serve web application files; they do not parse XML, so we do not consider the vulnerability exploitable in Kastoria Health deployments.
- Limits on rendered image size. Requests for very large rendered images or thumbnails are now refused, closing a route by which a single request could exhaust the service's memory and affect other users.
Changes to the DICOM Conformance Statement
The DICOM Conformance Statement has been updated for this release:
| Area | Change |
|---|---|
| Search (QIDO-RS) | Added search for instances in a study and in a series, with their supported search parameters and default response attributes. |
| Rendered images | Viewport crop coordinates are now supported. Removed from the list of current limitations; the remaining limitation is that empty and negative crop values are refused. |
| Rendered images and thumbnails | Documented the maximum viewport area: 16,777,216 pixels (for example 4096 × 4096) for rendered images, and 1,048,576 pixels (for example 1024 × 1024) for thumbnails. |
| Store (STOW-RS) | Documented that Big Endian and Deflated images are converted to Explicit VR Little Endian on ingest, even when ingest-time transcoding is turned off. |
| Transfer syntaxes | Added Deflated Explicit VR Little Endian as accepted on ingest. Big Endian remains available as a retrieval format; Deflated is not offered as one. |
Behavior to be aware of
- Big Endian and Deflated images are stored in a different encoding. Their content, including pixel data, is preserved exactly, but the stored data is Explicit VR Little Endian, so it is not byte-for-byte identical to the file that was sent.
- Oversized image requests are refused. A rendered image request larger than 16,777,216 pixels,
or a thumbnail request larger than 1,048,576 pixels, now returns
400 (Bad Request). Viewers should request images at display size. - Reprocessing always produces an update. Reprocessing a study now always creates a new version
of it, even when nothing has changed, and Event Notification announces it as
updated. Integrations that consume Notification Events should expect these events when reprocessing.
Kastoria Health v0.9.1 Release Notes
About this release
Kastoria Health v0.9.1 is the first release of Kastoria Health, the control plane for medical imaging. It provides DICOMweb ingestion, query and retrieval over versioned, content-addressed storage; scheduled study processing; event notifications carrying each study's Ingestion Manifest; SMART on FHIR launch from an EHR into a web viewer; an Admin Console; and OpenTelemetry-based monitoring.
The architecture and features are described in the Kastoria Health System Overview. These notes summarize what the release contains, the standards and platform it supports, and behavior to be aware of.
Audience
These notes are intended for CIOs, CTOs, security reviewers, enterprise architects, and senior developers evaluating, deploying, or integrating Kastoria Health.
Highlights
- Kastoria Core data plane. All data is stored immutably, content-addressed by SHA-256 and versioned, with a complete, hash-linked history of every study.
- DICOMweb. STOW-RS ingestion with lossless HTJ2K compression, QIDO-RS study and series search, and WADO-RS retrieval of studies, series, instances, frames, metadata, rendered images and thumbnails, with on-the-fly transcoding.
- Study processing. Stored studies are indexed for search and assigned FHIR Patient and ImagingStudy ids, with automatic retry of transient failures and operator-requested reprocessing.
- Event notification. A Notification Event carrying the study's Ingestion Manifest is published to an Azure Storage Queue each time a study is processed, with at-least-once delivery and re-publishing from a point in time.
- SMART on FHIR launch. Clinicians open a study from their EHR in the web viewer using SMART App Launch v2.2, with study-scoped access for the viewer.
- Admin Console. EHR launch configuration, Content Explorer, ingestion error log, processing and event monitoring, reprocessing, and a Changelog browser.
- Telemetry and monitoring. Traces and metrics from every service through OpenTelemetry, with Grafana dashboards.
What is included
Container images
| Image | Description |
|---|---|
| kastoria-proxy | Gateway: the single entry point that routes requests to every service. |
| kastoria-proxy-mtls | Mutual-TLS gateway that grants each client certificate access to specific services. |
| kastoria-health | DICOMweb service: STOW-RS ingestion, QIDO-RS query and WADO-RS retrieval. |
| kastoria-studyprocessor | Scheduled job that processes stored studies for query and retrieval. |
| kastoria-eventnotification | Scheduled job that publishes Notification Events to a queue. |
| kastoria-smart-launch-api | SMART on FHIR EHR launch service. |
| kastoria-ohif | Web viewer, built on the open-source OHIF Viewer. |
| kastoria-consoleapi | Admin Console API. |
| kastoria-consoleui | Admin Console web application. |
Every image is signed and carries SLSA provenance and a software bill of materials (SBOM). Images run as non-privileged users and are released only with no known critical or high severity vulnerabilities.
Deployment modules
Signed OpenTofu modules for deploying Kastoria Health on Azure: networking, secrets, object store, file store, queue, compute, gateway and telemetry, together with a sample deployment configuration.
Grafana dashboards
Services Overview, DICOMweb Performance, Admin Console Performance and Performance Test dashboards.
Supported standards
| Standard | Support |
|---|---|
| DICOMweb (DICOM PS3.18) | STOW-RS, QIDO-RS and WADO-RS, as detailed in the DICOM Conformance Statement. |
| SMART App Launch v2.2 | EHR launch, with PKCE. |
| OpenTelemetry | Traces and metrics exported over OTLP. |
Platform requirements
Kastoria Health v0.9.1 is deployable on Microsoft Azure, in the customer's own environment. It uses:
- Azure Container Apps, for services and scheduled jobs;
- Azure Storage (Blob and Table), for Kastoria Core;
- Azure Storage Queue, for Notification Events;
- Azure Key Vault, for secrets;
- Azure managed identities and Microsoft Entra ID, for access to all of the above; and
- Azure Monitor (Log Analytics), for logs.
Grafana dashboards require a Grafana instance, such as Grafana Cloud.
Behavior to be aware of
- Studies are available once processed. A newly stored study can be searched and retrieved, and its Notification Event is published, only after a scheduled processing run has processed it. The delay depends on the schedule configured for the deployment.
- Images are compressed on ingestion. Images from modalities such as CT, MR, CR, DX and MG are stored in HTJ2K lossless format unless compression is turned off for the request.
- Every store creates a new study version. Each STOW-RS request for a study creates a new version of it and causes it to be processed again.
- One FHIR Patient per Patient ID. Kastoria Health records one FHIR Patient resource for each DICOM Patient ID. When several studies share a Patient ID, the Patient reflects the most recently processed study. A study with no Patient ID has no Patient resource.
- DICOM identifiers are assumed unique. This release assumes Study Instance UIDs and Patient IDs are unique across the data it ingests. Data should be validated before it is migrated to Kastoria Health.