FROST: OGC SensorThings server

FROST-Server is a certified open source implementation of the OGC SensorThings API written in Java. It implements the OGC SensorThings API v1.1 (and optionally v2.0) over HTTP and MQTT. It is designed to be modular, extensible, and deployable in various environments, including Docker and Kubernetes.
Project Structure, Releases, Upgrading, Docker & Custom Auth
This post is a deep-dive companion to the documentation in the repository, written to answer:
- How is the project structured?
- What changed between releases?
- Can I safely upgrade from 2.1 or 2.6 straight to 2.7 or 2.8?
- How do I build my own Docker image instead of using the official ones?
- How do I implement a custom authentication/authorisation module?
My analysis is based on the actual sources in the repository (pom.xml files, CHANGELOG.md, docs/, .github/workflows/) rather than external documentation, so it reflects the exact state of the checked-out tree (bf7976, end of July 2026).
1. Project structure
FROST-Server is a multi-module Maven project (root pom.xml, groupId=de.fraunhofer.iosb.ilt.FROST-Server, current version 2.9.0-SNAPSHOT, Java 21). It implements the OGC SensorThings API and is built around a plugin architecture: almost everything (data model, API endpoints, result formats, auth) is a plugin loaded at runtime.
1.1 Top-level layout
FROST-Server/
├── pom.xml Parent POM: shared deps/plugins, modules list, profiles
├── CHANGELOG.md Human-curated changelog per minor version
├── README.md Quick start / overview
├── FROST-Server.Core.Model Internal data model classes (Entity, EntityType, PropertyType, ...)
├── FROST-Server.Core The core engine: query parsing, service logic, settings,
│ persistence abstraction, plugin manager, AuthProvider SPI
├── FROST-Server.SQLjooq jOOQ-based SQL persistence backend (Postgres/PostGIS, MariaDB)
├── FROST-Server.HTTP.Common Shared HTTP servlet/filter plumbing
├── FROST-Server.HTTP WAR: HTTP-only server (deployable in Tomcat/Wildfly or Docker)
├── FROST-Server.MQTT MQTT bindings (topic handling, message bus)
├── FROST-Server.MQTT.Moquette MQTT broker implementation, based on the Moquette library
├── FROST-Server.MQTTP WAR/JAR: combined HTTP + MQTT "all-in-one" server
├── FROST-Server.Auth.Basic Auth plugin: HTTP Basic-Auth backed by a database table
├── FROST-Server.Auth.Keycloak Auth plugin: Keycloak/OpenID-Connect based auth
├── FROST-Server.Util Generic utility classes shared across modules
├── FROST-Server.Tests Integration tests (spins up the server + DB, e.g. via Testcontainers)
├── Plugins/ Optional/pluggable functionality (see §1.2)
├── Tools/ ModelEditor & ModelExtractor desktop tools (see §1.3)
├── docs/ Jekyll source for the documentation website (GitHub Pages)
├── helm/ Helm chart for Kubernetes deployment
├── scripts/ Example docker-compose files (all-in-one, separated, keycloak, prometheus)
├── .github/workflows/ CI: build, test, sonar, release, Docker publish, Helm publish
├── mvnw / mvnw.cmd, .mvn/ Maven wrapper (no local Maven install required)
└── nb-configuration.xml, sonar-project.properties, license-header, ...
1.2 The Plugins/ module
Plugins is itself an aggregator POM. Each sub-folder is an independently versioned Maven module implementing one pluggable concern. Plugins are toggled at runtime with plugins.<name>.enable=true/false settings (see docs/settings/plugins.md).
| Plugin | Purpose |
|---|---|
CoreModel | The SensorThings API v1.1 data model (default, plugins.coreModel.enable=true) |
CoreModelV2 | The draft SensorThings API v2.0 data model (opt-in, since 2.8.0) |
ModelOM, ModelRelations, ModelSampling | Building blocks of the v2.0 data model |
MultiDatastream | The MultiDatastream extension to v1.1 |
Actuation | OGC SensorThings API Part 2: Tasking Core |
ModelLoader | Loads custom/extended data models, Liquibase files, and fine-grained security rule files from JSON — the mechanism used to extend or replace the data model without writing Java code |
Projects | Fine-grained authorisation data model (Projects/Users/Roles), requires ModelLoader |
FormatCsv, FormatDataArray, FormatGeoJson | Alternative result (de)serialisation formats |
BatchProcessing | OGC “Batch Requests” extension (also JSON-Batch) |
OData | Experimental OData 4.0 / 4.01 endpoint |
OpenApi | Generates an OpenAPI description of the API |
OpenCitySense | A custom data-model extension example |
Plugins are discovered via plugins.providedPlugins (bundled defaults) and plugins.plugins (extra classes you add to the classpath) — the same mechanism used to register a custom AuthProvider (see §5).
1.3 Tools/
- ModelEditor — a JavaFX desktop GUI for creating/editing the JSON model and security files consumed by the
ModelLoaderplugin. - ModelExtractor — generates a starting model file from an existing database schema.
1.4 Runtime architecture (how a request flows)
HTTP request ──> Servlet Filter chain (AuthProvider.addFilter, CORS, ...)
──> Service dispatcher (per API-version plugin, e.g. CoreModel v1.1 or CoreModelV2)
──> Query/Path parser (FROST-Server.Core)
──> PersistenceManager (FROST-Server.SQLjooq, generated jOOQ SQL)
──> PostgreSQL/PostGIS or MariaDB
──> ResultFormatter plugin (JSON / GeoJSON / CSV / DataArray)
MQTT publish ──> FROST-Server.MQTT(.Moquette) ──> same persistence/query layer,
with fine-grained-auth filtering applied per subscriber (since 2.8.0)
Database schema management is handled by Liquibase: every data-model plugin (and the Auth.Basic plugin) ships its own Liquibase changelog under src/main/resources/.../liquibase. These are applied via the /DatabaseStatus web endpoint (or persistence.autoUpdateDatabase=true and auth.autoUpdateDatabase=true), which is also how version-to-version schema upgrades are performed (see §3).
1.5 Packaging artifacts
Three deployable artifacts come out of a build:
FROST-Server.HTTP→ WAR, HTTP-only.FROST-Server.MQTT→ executable JAR (-jar-with-dependencies), MQTT-only, standalone broker.FROST-Server.MQTTP→ WAR, combined HTTP + MQTT (“all-in-one”, what the plainfrost-serverDocker image uses).
2. Releases and what changed between them
Full details are in CHANGELOG.md; the summary below highlights the major/breaking points per minor version (patch releases, e.g. 2.6.1–2.6.4 or 2.7.1–2.7.3, contain bugfixes only, no schema or config changes).
| Version | Date (this repo’s tags) | Highlights |
|---|---|---|
| 2.0.0 | 2022-05-09 | Full rewrite onto a plugin architecture: data model, API endpoints and result formats all became plugins. Stricter relation-name filtering than 1.x. $resultMetadata no longer allowed inside $expand. Upgrade path from 1.x: update to 1.14/1.15 first, run its DB-upgrade, then move to 2.x. |
| 2.1.0 | 2023-02-15 | Requires Java 17. Hashed-password support for Basic Auth, PostgreSQL Row-Level-Security role setting, unlinking many-to-many relations, misc bugfixes. |
| 2.2.0 | 2023-09-26 | Fine-Grained Authorization introduced. $filter=prop eq/ne null, symmetrical many-to-many self-relations, OData in keyword, DrawIO metadata output. |
| 2.3.0 | 2024-02-23 | JSON-Batch header support (enables JSON-Patch-in-batch), MQTT $expand, KeyCloak local user registration. Helm note: the frost.http.ingress.rewriteTarget option fixed a typo (was rewriteTraget) — check Helm values if you use it. |
| 2.4.0 | 2024-08-30 | OData any() filters, MQTT $filter support, plugin load-order relaxed, streaming Batch-Requests, auto-detect serviceRootUrl, Tomcat Remote-IP-Filter support. |
| 2.5.0 | 2024-11-18 | Requires Java 21 and Jakarta EE 10 / Tomcat 10+ (moved off javax.*). Spatial queries on MariaDB. Performance improvements to insert/update (skip redundant re-select). |
| 2.6.0 | 2025-06-23 | Projects plugin: automatic obscreate/obsupdate/obsdelete/obspropcreate/... roles. Metrics support. EWKT query support. MQTT anonymous connections rejected when anonymous read is disabled. |
| 2.7.0 | 2026-01-08 | Sensor/Actuator metadata can be JSON and queried as such. Optional timezone parameter on datetime functions. Query functions made pluggable (PluginFunction). Tomcat HTTP compression enabled. Error on constraint-violation on insert changed from 500 → 409. |
| 2.8.0 | 2026-07-27 | Fine-grained authorisation extended to MQTT topic subscriptions. GZip/deflate HTTP upload support. Draft SensorThings API v2.0 exposed via a full HTTP+MQTT(v5 request/response) binding (CoreModelV2/coreServiceV2 plugins, opt-in, disabled by default). Nested complex-property handling. |
| 2.9.0-SNAPSHOT | in development | Not yet released. |
Java/runtime requirement timeline (important for upgrade planning): Java 17 from 2.1.0 onward; Java 21 and Jakarta EE 10 / Tomcat 10 from 2.5.0 onward. If you are still on 2.1–2.4 you are likely on Java 17/Tomcat 9; moving to 2.7/2.8 also means moving your servlet container to Tomcat 10+ (the official Docker images already use tomcat:10-jre21, so this is handled for you if you use them).
3. Can you safely upgrade from 2.1 / 2.6 straight to 2.7 or 2.8?
Short answer: yes, in general it is supported and low-risk, but plan for two things: the Java/Tomcat runtime bump, and always let Liquibase run the schema migration rather than skipping it. FROST-Server does not require you to install every minor version sequentially — Liquibase changesets are cumulative and additive, and the migration mechanism (/DatabaseStatus, or persistence.autoUpdateDatabase=true and auth.autoUpdateDatabase=true) always brings the schema from whatever state it’s in up to the version you deploy.
Concretely:
- Database schema: Every version’s Liquibase changelogs are additive on top of previous ones (new columns/tables, not destructive rewrites). Deploying 2.7.0/2.8.0 directly against a database that was last touched by 2.1.0 or 2.6.0 works the same way as deploying it after every intermediate version — visit
/FROST-Server/DatabaseStatus(or enablepersistence.autoUpdateDatabase=trueandauth.autoUpdateDatabase=true) and it will apply every pending changeset in order. - From 2.1.x → 2.7/2.8: No documented breaking API/config change is listed between 2.1 and 2.8 in
CHANGELOG.mdother than:- The Java 21 / Jakarta EE10 / Tomcat 10 requirement introduced at 2.5.0 — you must upgrade your JVM and (if not using Docker) your servlet container.
- The 2.3.0 Helm chart rename of
rewriteTraget→rewriteTarget(only relevant if you deploy via the Helm chart and set that value). - The error code for constraint-violations on insert changed from 500 to 409 (2.7.0) — only matters if client code specifically checks for HTTP 500 on duplicate/constraint errors.
- From 2.6.x → 2.7/2.8: Same as above minus the Java/Tomcat bump (2.6 is already on Java 21/Tomcat 10). The only new opt-in surface is the v2.0 API/data model (
plugins.coreModelV2.enable,plugins.coreServiceV2.enable), which defaults to disabled, and MQTT fine-grained auth (auth.mqtt.fineGrainedAuth), which also defaults to disabled — so a plain upgrade with your existing settings does not change existing v1.0/v1.1 behaviour. - No version in this range removed a data-model plugin, changed default IDs, or required a manual data migration script beyond what Liquibase performs automatically.
Recommended upgrade procedure regardless of source version:
- Back up your PostgreSQL/PostGIS (or MariaDB) database before touching anything — this is the only truly irreversible step.
- Read the
CHANGELOG.mdentries for every version between your current one and the target (2.1→2.8 spans 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8) — the summary table above condenses them, but check for any setting you actually use. - If self-hosted (non-Docker): upgrade the JVM to 21 and the servlet container to Tomcat 10+ (or Wildfly equivalent) before deploying 2.5+.
- Deploy the new WAR/JAR (or pull the new Docker image tag) pointing at the same database.
- Browse to
/FROST-Server/DatabaseStatusand click “upgrade” (or setpersistence.autoUpdateDatabase=trueandauth.autoUpdateDatabase=trueso it happens automatically on boot). - Smoke-test your existing client integrations (in particular anything depending on specific HTTP status codes, since 2.7.0 changed one).
If you want extra safety, upgrading via the intermediate minor releases (e.g., test against 2.6 before jumping to 2.8) costs little and lets you attribute any regression to a specific version, but it is not required by the migration tooling.
4. Building your own Docker image (instead of the official ones)
The official images (fraunhoferiosb/frost-server, -http, -mqtt) are built by CI (.github/workflows/maven-build.yml / maven-deploy.yml) using the Dockerfiles already in this repo — you can run the exact same steps yourself, which is the simplest way to get a custom image (e.g. with extra JDBC drivers, a custom AuthProvider, or extra plugin JARs baked in).
4.1 The three Dockerfiles
FROST-Server.HTTP/Dockerfile, FROST-Server.MQTT/Dockerfile, and FROST-Server.MQTTP/Dockerfile are nearly identical:
FROM tomcat:10-jre21
RUN sed -i 's/\(<Connector port="8080" protocol="HTTP\/1.1"\)/\1 compression="on" /' conf/server.xml
RUN apt-get update && apt-get install unzip && apt-get clean
ARG ARTIFACT_FILE
COPY target/${ARTIFACT_FILE} /tmp/FROST-Server.war
RUN unzip -d ${CATALINA_HOME}/webapps/FROST-Server /tmp/FROST-Server.war \
&& rm /tmp/FROST-Server.war \
&& groupadd --system --gid 1001 tomcat \
&& useradd --system --uid 1001 --gid 1001 tomcat \
&& chgrp -R 0 $CATALINA_HOME && chmod -R g=u $CATALINA_HOME \
&& chown tomcat:tomcat $JAVA_HOME/lib/security/cacerts
COPY target/docker_deps/ ${CATALINA_HOME}/webapps/FROST-Server/WEB-INF/lib/
USER tomcat
target/docker_deps/ is populated during the Maven build by the maven-dependency-plugin (docker_deps execution in each module’s pom.xml) and contains the PostgreSQL, PostGIS and MariaDB JDBC drivers — these live outside the WAR so Tomcat can share them, but are copied into WEB-INF/lib.
4.2 Build it yourself, unmodified
# 1. Build the WAR/JAR artifacts (from repo root)
./mvnw clean install -DskipTests
# 2. Note the exact artifact filename produced (version-dependent), e.g.:
ls FROST-Server.HTTP/target/*.war
ls FROST-Server.MQTTP/target/*.war
ls FROST-Server.MQTT/target/*-jar-with-dependencies.jar
# 3. Build the image, context = module directory, passing the artifact name
docker build \
--build-arg ARTIFACT_FILE=FROST-Server.HTTP-2.9.0-SNAPSHOT.war \
-t my-org/frost-server-http:custom \
./FROST-Server.HTTP
Repeat for FROST-Server.MQTT (ARTIFACT_FILE=...-jar-with-dependencies.jar) and/or FROST-Server.MQTTP (all-in-one) as needed. This is exactly what CI does via docker/build-push-action, just locally and unauthenticated.
4.3 Customising the image
Common reasons to build your own image and how to do it, in increasing order of invasiveness:
Add a JDBC driver / extra library: add another
COPYline for the jar intoWEB-INF/lib, or add it as adocker_depsdependency in the module’spom.xmlbefore building.Add a custom Auth provider or Plugin jar (no source changes needed): build your jar separately (see §5), then extend the Dockerfile (or write a second-stage
FROM your-org/frost-server-http:customimage) that does:COPY my-custom-auth-1.0.0.jar ${CATALINA_HOME}/webapps/FROST-Server/WEB-INF/lib/and set
auth.provider=com.example.MyAuthProvidervia environment variable orcontext.xml/web.xml.Change base image / JVM / Tomcat version: edit the
FROM tomcat:10-jre21line in your own copy of the Dockerfile.Rebuild from source with code changes: fork/patch the Java sources,
./mvnw clean install, then build the Docker image as in §4.2 — this is the normal path if you’re not just using official releases.
4.4 Running your custom image
Reuse the provided compose files as templates (scripts/docker-compose.yaml for all-in-one, scripts/docker-compose-separated*.yaml for split HTTP/MQTT + Mosquitto message bus, scripts/example-keycloak/* for Keycloak-based auth) — just replace the image: entries with your locally built tags.
5. Implementing a custom authentication/authorisation module
Auth is a pluggable SPI, exactly like the data-model plugins. You do not need to fork FROST-Server — you implement one interface, package it as a jar, and point auth.provider at your class.
5.1 The interfaces to implement
de.fraunhofer.iosb.ilt.frostserver.util.AuthProvider (FROST-Server.Core/.../util/AuthProvider.java), which extends LiquibaseUser:
public interface AuthProvider extends LiquibaseUser {
UserCaches getUserCaches();
// Register your servlet Filter(s) on the given ServletContext.
void addFilter(Object context, CoreSettings coreSettings);
// Called by both HTTP Basic-Auth and MQTT CONNECT handling.
boolean isValidUser(String clientId, String username, String password);
// Called for every authorisation check: read/create/update/delete/admin.
boolean userHasRole(String clientId, String userName, String roleName);
}
LiquibaseUser (FROST-Server.Core/.../util/LiquibaseUser.java) is the hook that lets your auth module manage its own database schema (e.g. a users table) via the same /DatabaseStatus upgrade mechanism as the rest of the server:
public interface LiquibaseUser {
InitResult init(CoreSettings coreSettings);
String checkForUpgrades(Map<String, Object> liquibaseParams);
boolean doUpgrades(Writer out, Map<String, Object> liquibaseParams) throws UpgradeFailedException, IOException;
Map<String, Object> createLiqibaseParams(PersistenceManager pm, Map<String, Object> target);
}
5.2 The five roles FROST checks
Regardless of your backing mechanism, userHasRole() will be asked about these role names (configurable via auth.role.* settings, defaults shown):
| Role | Default name | Meaning |
|---|---|---|
| read | read | GET (HTTP + MQTT subscribe) |
| create | create | POST (HTTP + MQTT publish/create) |
| update | update | PUT/PATCH (HTTP only) |
| delete | delete | DELETE (HTTP only) |
| admin | admin | /DatabaseStatus access (HTTP only) |
If you only want authentication (identify the user) and delegate authorisation elsewhere (e.g. Row-Level-Security or a fine-grained-auth plugin), set auth.authenticateOnly=true and always return true from userHasRole().
5.3 Reference implementation to study
FROST-Server.Auth.Basic (BasicAuthProvider, BasicAuthFilter, DatabaseHandler) is the simplest complete example: it stores users/roles in plain database tables, exposes settings via @DefaultValue/ConfigDefaults annotations, and manages its own Liquibase changesets under src/main/resources/liquibase. FROST-Server.Auth.Keycloak shows a more involved example that talks to an external IdP and optionally mirrors users into a local table via a pluggable UserRoleDecoder.
5.4 Building and wiring in a new auth module
Create a new Maven module (or a standalone jar project) with a dependency on
FROST-Server.Core(for theAuthProvider/LiquibaseUserinterfaces andCoreSettings) — no need to depend onFROST-Server.HTTPor.MQTTdirectly unless you need their filter base classes.Implement
AuthProvider:init()— read your settings (coreSettings.getAuthSettings()), open any resources.addFilter()— register ajakarta.servlet.Filteron the HTTPServletContextthat authenticates incoming requests and attaches aPrincipalExtended(seede.fraunhofer.iosb.ilt.frostserver.util.user.PrincipalExtended) to the request — this is how downstream code learns “who is this user and what roles do they have”.isValidUser()/userHasRole()— implement against your identity store (LDAP, OAuth2 introspection, a custom DB table, an API call, …). These are also invoked from the MQTT side (clientIdis populated only there), so a single implementation covers both HTTP and MQTT.- Implement the four
LiquibaseUsermethods — you can return “no upgrades needed” trivially if you don’t manage your own schema, or ship Liquibase XML/YAML changesets on the classpath if you do (see the Basic module’ssrc/main/resources/liquibasefor the layout).
Expose configuration using the
@DefaultValue,@DefaultValueBoolean,@DefaultValueIntannotations andConfigDefaults, followingBasicAuthProvider’s pattern, so your settings show up alongside the built-inauth.*settings and can be set via environment variables (the same mechanism the whole project uses, seedocs/settings/settings.md).Package your module as a jar (
mvn package), and place it on the classpath:- Tomcat/Wildfly deployment: drop the jar in
WEB-INF/libof the deployed WAR (or$CATALINA_HOME/libif shared across webapps). - Docker:
COPYit into${CATALINA_HOME}/webapps/FROST-Server/WEB-INF/lib/in your own Dockerfile layered on top of the official/custom image (see §4.3).
- Tomcat/Wildfly deployment: drop the jar in
Activate it by setting:
auth.provider=com.example.myauth.MyAuthProviderplus any custom settings your class defines, either as environment variables (Docker/Tomcat) or in
context.xml/web.xml.
5.5 Beyond a full custom AuthProvider: fine-grained rules without Java
If you only need finer-grained authorisation rules (e.g. “user X may only read/write Observations belonging to their own Datastreams”) rather than a different authentication mechanism, you likely don’t need a new AuthProvider at all. Options, in increasing complexity, per docs/settings/auth.md:
- PostgreSQL Row-Level Security via
persistence.transactionRole. ModelLoaderSecurity Wrappers/Validators — JSON rule files loaded by theModelLoaderplugin (editable with theModelEditortool), applied per entity type/relation, and (since 2.8.0) enforceable on MQTT subscriptions too viaauth.mqtt.fineGrainedAuth.- The Projects plugin is a ready-made data model + rule set built on top of this mechanism if “users belong to projects with per-project roles” matches your use case.
Both approaches can be combined with FROST-Server.Auth.Basic or FROST-Server.Auth.Keycloak for authentication, keeping your custom code limited to policy/rule files instead of a full Java AuthProvider.