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:

  1. How is the project structured?
  2. What changed between releases?
  3. Can I safely upgrade from 2.1 or 2.6 straight to 2.7 or 2.8?
  4. How do I build my own Docker image instead of using the official ones?
  5. 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).

PluginPurpose
CoreModelThe SensorThings API v1.1 data model (default, plugins.coreModel.enable=true)
CoreModelV2The draft SensorThings API v2.0 data model (opt-in, since 2.8.0)
ModelOM, ModelRelations, ModelSamplingBuilding blocks of the v2.0 data model
MultiDatastreamThe MultiDatastream extension to v1.1
ActuationOGC SensorThings API Part 2: Tasking Core
ModelLoaderLoads 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
ProjectsFine-grained authorisation data model (Projects/Users/Roles), requires ModelLoader
FormatCsv, FormatDataArray, FormatGeoJsonAlternative result (de)serialisation formats
BatchProcessingOGC “Batch Requests” extension (also JSON-Batch)
ODataExperimental OData 4.0 / 4.01 endpoint
OpenApiGenerates an OpenAPI description of the API
OpenCitySenseA 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 ModelLoader plugin.
  • 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 plain frost-server Docker 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).

VersionDate (this repo’s tags)Highlights
2.0.02022-05-09Full 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.02023-02-15Requires Java 17. Hashed-password support for Basic Auth, PostgreSQL Row-Level-Security role setting, unlinking many-to-many relations, misc bugfixes.
2.2.02023-09-26Fine-Grained Authorization introduced. $filter=prop eq/ne null, symmetrical many-to-many self-relations, OData in keyword, DrawIO metadata output.
2.3.02024-02-23JSON-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.02024-08-30OData any() filters, MQTT $filter support, plugin load-order relaxed, streaming Batch-Requests, auto-detect serviceRootUrl, Tomcat Remote-IP-Filter support.
2.5.02024-11-18Requires 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.02025-06-23Projects plugin: automatic obscreate/obsupdate/obsdelete/obspropcreate/... roles. Metrics support. EWKT query support. MQTT anonymous connections rejected when anonymous read is disabled.
2.7.02026-01-08Sensor/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.02026-07-27Fine-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-SNAPSHOTin developmentNot 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 enable persistence.autoUpdateDatabase=true and auth.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.md other 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 rewriteTragetrewriteTarget (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:

  1. Back up your PostgreSQL/PostGIS (or MariaDB) database before touching anything — this is the only truly irreversible step.
  2. Read the CHANGELOG.md entries 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.
  3. If self-hosted (non-Docker): upgrade the JVM to 21 and the servlet container to Tomcat 10+ (or Wildfly equivalent) before deploying 2.5+.
  4. Deploy the new WAR/JAR (or pull the new Docker image tag) pointing at the same database.
  5. Browse to /FROST-Server/DatabaseStatus and click “upgrade” (or set persistence.autoUpdateDatabase=true and auth.autoUpdateDatabase=true so it happens automatically on boot).
  6. 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 COPY line for the jar into WEB-INF/lib, or add it as a docker_deps dependency in the module’s pom.xml before 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:custom image) that does:

    COPY my-custom-auth-1.0.0.jar ${CATALINA_HOME}/webapps/FROST-Server/WEB-INF/lib/
    

    and set auth.provider=com.example.MyAuthProvider via environment variable or context.xml/web.xml.

  • Change base image / JVM / Tomcat version: edit the FROM tomcat:10-jre21 line 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):

RoleDefault nameMeaning
readreadGET (HTTP + MQTT subscribe)
createcreatePOST (HTTP + MQTT publish/create)
updateupdatePUT/PATCH (HTTP only)
deletedeleteDELETE (HTTP only)
adminadmin/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

  1. Create a new Maven module (or a standalone jar project) with a dependency on FROST-Server.Core (for the AuthProvider/LiquibaseUser interfaces and CoreSettings) — no need to depend on FROST-Server.HTTP or .MQTT directly unless you need their filter base classes.

  2. Implement AuthProvider:

    • init() — read your settings (coreSettings.getAuthSettings()), open any resources.
    • addFilter() — register a jakarta.servlet.Filter on the HTTP ServletContext that authenticates incoming requests and attaches a PrincipalExtended (see de.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 (clientId is populated only there), so a single implementation covers both HTTP and MQTT.
    • Implement the four LiquibaseUser methods — 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’s src/main/resources/liquibase for the layout).
  3. Expose configuration using the @DefaultValue, @DefaultValueBoolean, @DefaultValueInt annotations and ConfigDefaults, following BasicAuthProvider’s pattern, so your settings show up alongside the built-in auth.* settings and can be set via environment variables (the same mechanism the whole project uses, see docs/settings/settings.md).

  4. Package your module as a jar (mvn package), and place it on the classpath:

    • Tomcat/Wildfly deployment: drop the jar in WEB-INF/lib of the deployed WAR (or $CATALINA_HOME/lib if shared across webapps).
    • Docker: COPY it into ${CATALINA_HOME}/webapps/FROST-Server/WEB-INF/lib/ in your own Dockerfile layered on top of the official/custom image (see §4.3).
  5. Activate it by setting:

    auth.provider=com.example.myauth.MyAuthProvider
    

    plus 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.
  • ModelLoader Security Wrappers/Validators — JSON rule files loaded by the ModelLoader plugin (editable with the ModelEditor tool), applied per entity type/relation, and (since 2.8.0) enforceable on MQTT subscriptions too via auth.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.