Docker images
The supported way to containerize a StoatFlow app is the StoatFlow build conventions' Jib integration — no Dockerfile, no Docker daemon to assemble layers. It produces a reproducible image wired with the JVM flags StoatFlow needs and an entrypoint that sizes the heap from the container's memory limit at startup. Both the Gradle plugin and the Maven conventions build the same image.
Build the image (Jib)
Container support is opt-in. Apply the conventions (see Project setup), set mainClass, and turn the docker build on:
// build.gradle.kts
stoatflow {
mainClass.set("com.example.MainKt")
docker {
enabled.set(true)
imageName.set("acme/word-count") // default: the Gradle project name
}
}
<!-- pom.xml -->
<properties>
<stoatflow.mainClass>com.example.Main</stoatflow.mainClass>
<stoatflow.docker.imageName>acme/word-count</stoatflow.docker.imageName>
</properties>
Then build the image into your local Docker daemon:
./gradlew jibDockerBuild
mvn -Pstoatflow-docker package # fat JAR + image, one command
Jib reads your compiled classes and dependencies and assembles an OCI image directly — no Dockerfile, no intermediate fat JAR to manage. The image is tagged latest and the project version, named after imageName (or the project name if unset).
jibDockerBuild / mvn -Pstoatflow-docker package needs a reachable Docker daemon to receive the image, but Jib does not run a docker build — it constructs the layers itself. On macOS the build resolves the docker executable from common install paths (/usr/local/bin/docker, /opt/homebrew/bin/docker) when the daemon's PATH doesn't include them.What the build configures
The Jib image has the following baked in:
| Aspect | Value | Knob |
|---|---|---|
| Base image | eclipse-temurin:25-jre-noble | baseImage |
| Platform | linux/arm64 on an aarch64 host, otherwise linux/amd64 | — |
| Tags | latest and the project version | — |
creationTime | HEAD commit timestamp (reproducible) | reproducibleBuild |
| Exposed port | 8080 — the runtime's HTTP + metrics server | port |
| User | 1000:1000 (non-root) | user |
| Working directory | /app | — |
| Entrypoint | a heap-sizing script (/app/stoatflow-entrypoint.sh) that execs java | — |
| JVM flags | --enable-preview, --enable-native-access=ALL-UNNAMED, -XX:+UseG1GC, plus your jvmFlags | jvmFlags |
| Env | STOATFLOW_STATE_DIR=/data/state; STOATFLOW_ROCKSDB_MB=256 when RocksDB is on the classpath | stateDir, rocksdbMb |
Every knob in the right column has the same name in the Gradle docker { } block and a stoatflow.docker.<name> Maven property — the full enumeration with types and defaults is in the Gradle plugin reference and the Maven reference.
JVM flags
The image always carries the flags StoatFlow requires at runtime, the same ones applied to the run task:
--enable-preview— StoatFlow is built on JDK preview features.--enable-native-access=ALL-UNNAMED— RocksDB's state store uses the Foreign Function & Memory API.-XX:+UseG1GC— short, predictable GC pauses, set explicitly because the JVM only auto-selects G1 on "server-class" hardware (≥ 2 CPUs and ≥ ~2 GB memory). See Installation for the reasoning.
Anything you add via jvmFlags is appended after these (and jvmFlags is itself overridable — the two preview/native-access flags are always re-added on top):
stoatflow {
docker {
enabled.set(true)
jvmFlags.add("-XX:+ExitOnOutOfMemoryError")
jvmFlags.add("-Dsome.property=value")
}
}
<properties>
<stoatflow.docker.jvmFlags>-XX:+UseG1GC -XX:+ExitOnOutOfMemoryError</stoatflow.docker.jvmFlags>
</properties>
Reproducible builds
By default the image is reproducible: its creationTime is pinned to your HEAD commit timestamp, not wall-clock — so identical source produces an identical digest with a meaningful Created date, friendly to layer caching and supply-chain verification. Outside a git repo the timestamp falls back to the Unix epoch. Set reproducibleBuild = false (Gradle) / <stoatflow.docker.reproducibleBuild>false</…> (Maven) to restore wall-clock timestamps.
Container-aware heap sizing
The Jib image does not hardcode -Xmx. Its entrypoint computes the heap from the memory available to the container at startup, reserving space for the JVM's own overhead and for RocksDB's off-heap usage, then execs java with the computed -Xmx. The same image right-sizes itself whether it runs with a 512 MiB limit or a 4 GiB limit — you set the container memory limit, not a JVM flag.
The memory limit is resolved from the first available source, in order:
- cgroup v2 (
/sys/fs/cgroup/memory.max) — modern containers (Ubuntu 22.04+, Kubernetes 1.25+). - cgroup v1 (
/sys/fs/cgroup/memory/memory.limit_in_bytes) — older runtimes. /proc/meminfo— bare-metal Linux.- If none report a limit, the entrypoint falls back to
-XX:MaxRAMPercentage=75.0and lets the JVM decide.
When a limit is found, the heap is the total minus a non-heap budget: a JVM baseline (25% of total, capped at 512 MiB) plus the RocksDB reservation. Two environment variables tune this:
| Variable | Default | Effect |
|---|---|---|
STOATFLOW_ROCKSDB_MB | 256 when RocksDB is on the classpath, else 0 | Off-heap MiB reserved for RocksDB. Subtracted from the heap. Set via the rocksdbMb knob at build time, or override at run time. |
STOATFLOW_NON_HEAP_MB | unset | Explicit total non-heap budget (overrides the baseline + RocksDB calculation). Clamped to ≤ 50% of total. |
The entrypoint logs the source, total, non-heap budget, and the chosen -Xmx to stderr at startup, so the sizing decision is visible in the container logs. If the resolved memory is too small to leave a viable heap, it logs a warning and forces a 128 MiB floor (expect an OOMKill — give the container more memory).
STOATFLOW_ROCKSDB_MB and STOATFLOW_NON_HEAP_MB must be plain integers (MiB) — e.g. 256, not 256m. The entrypoint fails fast on a non-numeric value.stoatflow-runtime/stoatflow-core bring RocksDB onto the classpath, the image sets STOATFLOW_ROCKSDB_MB=256 for you. In-memory-only apps omit it. Override with the rocksdbMb knob, or 0 to omit. The native runtime image uses the same entrypoint and the same reservation.Run the container
The image exposes port 8080 — the runtime's HTTP admin + Prometheus metrics server (see REST API and Metrics) — and reads its own configuration (bootstrap servers, license key, topics) from application.yaml and environment overrides (see Runtime config). Mount a volume at STOATFLOW_STATE_DIR to persist RocksDB state across restarts:
docker run --rm \
-e KAFKA_BOOTSTRAP_SERVERS=host.docker.internal:9092 \
-e STOATFLOW_LICENSE_KEY="key/...your key..." \
-v stoatflow-state:/data/state \
-p 8080:8080 \
acme/word-count:latest
A Kubernetes deployment then needs only a memory limit (the entrypoint sizes the heap from it) and a volume for the state dir — see Kubernetes:
resources:
limits:
memory: 2Gi # the entrypoint sizes the heap from this
volumeMounts:
- { name: state, mountPath: /data/state } # matches stateDir
Rolling your own Dockerfile
The Jib path is the supported, simplest route and right-sizes the heap automatically — prefer it. If your pipeline standardizes on docker build, build the fat JAR (./gradlew shadowJar / mvn package) and write a thin image over it: a eclipse-temurin:25-jre-noble base, COPY the -all.jar, and launch java --enable-preview --enable-native-access=ALL-UNNAMED -XX:+UseG1GC -jar app.jar. You lose the automatic container-aware heap sizing — pin -Xmx or use -XX:MaxRAMPercentage yourself.
For a native image (GraalVM), the containerized build is a docker build against a bundled, repo-independent Dockerfile.native — see Native image.
Next steps
- Project setup — the full build-conventions surface, including the
dockerandnativeImageblocks. - Gradle plugin reference · Maven reference — every property, task, and default.
- Native image — the GraalVM native-image build for fast startup and low memory.
- Health checks — the liveness/readiness endpoints the container probes hit.
Plugins and lifecycle hooks
Extend the StoatFlow runtime with custom metrics, HTTP endpoints, and health indicators via the RuntimePlugin interface and lifecycle listeners.
GraalVM native image
Compile your StoatFlow app to a GraalVM native binary — a one-line opt-in. The framework ships its own reflection/JNI/FFM metadata; you choose the GC, optionally PGO, and add metadata only for your own serde value classes.