Docker images

Build a container image for your StoatFlow app with the StoatFlow build conventions (Jib) — base image, JVM flags, reproducible builds, container-aware heap sizing, ports and env — on Gradle or Maven.

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
    }
}

Then build the image into your local Docker daemon:

./gradlew jibDockerBuild

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:

AspectValueKnob
Base imageeclipse-temurin:25-jre-noblebaseImage
Platformlinux/arm64 on an aarch64 host, otherwise linux/amd64
Tagslatest and the project version
creationTimeHEAD commit timestamp (reproducible)reproducibleBuild
Exposed port8080 — the runtime's HTTP + metrics serverport
User1000:1000 (non-root)user
Working directory/app
Entrypointa heap-sizing script (/app/stoatflow-entrypoint.sh) that execs java
JVM flags--enable-preview, --enable-native-access=ALL-UNNAMED, -XX:+UseG1GC, plus your jvmFlagsjvmFlags
EnvSTOATFLOW_STATE_DIR=/data/state; STOATFLOW_ROCKSDB_MB=256 when RocksDB is on the classpathstateDir, 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")
    }
}

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:

  1. cgroup v2 (/sys/fs/cgroup/memory.max) — modern containers (Ubuntu 22.04+, Kubernetes 1.25+).
  2. cgroup v1 (/sys/fs/cgroup/memory/memory.limit_in_bytes) — older runtimes.
  3. /proc/meminfo — bare-metal Linux.
  4. If none report a limit, the entrypoint falls back to -XX:MaxRAMPercentage=75.0 and 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:

VariableDefaultEffect
STOATFLOW_ROCKSDB_MB256 when RocksDB is on the classpath, else 0Off-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_MBunsetExplicit 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.
RocksDB reservation is automatic. Because 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