Native Image (experimental)
September 3, 2026 ยท View on GitHub
ArcadeDB can be compiled into a native binary of the ArcadeDB server using GraalVM Native Image. The native binary starts in a fraction of a second and uses far less RAM than the equivalent JVM process, which makes it attractive for containers, serverless-style deployments, and quick local testing.
This is an experimental add-on, built and published from a dedicated native Maven module and
a separate -Pnative profile / CI workflow. It does not replace the standard JVM distribution:
the JVM build remains the default, fully supported way to run ArcadeDB, carries the full module set
(including Gremlin), and is what every other doc on this site assumes. Reach for the native image
when fast startup and a small memory footprint matter more than having every module available, and
treat it as best-effort outside Linux.
Prerequisites
-
GraalVM CE 25.0.2. The build uses the GraalVM Native Image Community Edition builder pinned by CI to
jdk-25.0.2viagraalvm/setup-graalvm. Install it the easy way - it is a mainline release, so the OS packagers carry it:brew install --cask graalvm-community-jdk25 # macOS: 25.0.2native/pom.xmlpins the GraalVM polyglot/Truffle artifacts (graal-sdk,polyglot,js-language,truffle-*, etc.) to the same25.0.2to match the builder exactly; a Truffle version skew between the builder and those artifacts fails the build at feature registration (NoSuchMethodError: OptimizedTruffleRuntime.getLoopNodeFactory()). That error names neither file, and two Dependabot bumps have shipped the skew, so.github/scripts/check-native-graalvm-pin.pyenforces the equality in the always-onlintjob. Move both sides in the same change, or neither.Being on Maven Central does not make a version installable.
25.0.3and25.0.4publish the full set oforg.graalvm.*artifacts but were never released as CE tarballs, so bumpingnative/pom.xmlto either resolves cleanly and then leaves no builder to match, on any platform and through any setup-graalvm input. As of 2026-09-03 the CE distributions are:Version CE tarball Maven artifacts 25.0.2 jdk-25.0.2yes pinned 25.0.3 none yes 25.0.4 none yes 25.1.3 graal-25.1.3yes intermediate 25.2.4 graal-25.2.4yes intermediate, the previous pin The workflow selects the builder with setup-graalvm's
java-versioninput, which fetches ajdk-<version>release and so reaches mainline builds only - which is the point. The other input,version, looks upgraal-<version>first and falls back tojdk-<version>, so it reaches the intermediate line too; the pin used it while it tracked 25.2.4.version: "25.0.2"would work here as well, andjava-versionis used to state the intent that the pin stays mainline.Any future pin must be a version whose tag parses as three-component semver.
graal-25.3.4.1is reachable through neither input: node-semver rejects its four numeric components, sofindLatestGraalVMCEVersionskips it as an "unexpected GraalVM CE release", finds nojdk-25.3.4.1either, and throws. It has been pinned here by mistake once already. -
JAVA_HOME(andGRAALVM_HOME) must point at the GraalVM home itself. Thenative-maven-pluginresolves the native-image builder fromJAVA_HOME/GRAALVM_HOME, not by searchingPATHfor anative-imageexecutable. A version-manager shim (jenv, sdkman shell shims, etc.) that only putsnative-imageonPATHis not sufficient - ifJAVA_HOMEstill points at a different JDK, the plugin fails withnative-image is not installed in your JAVA_HOME. Export both explicitly before building locally, e.g. on macOS:export JAVA_HOME=/path/to/graalvm-community-25.0.2/Contents/Home export GRAALVM_HOME=$JAVA_HOMEgraalvm/setup-graalvmsets both variables automatically in CI. -
musl-tools/musl-devare only needed to build the fully-staticlinux/amd64binary that feeds thescratch-based Docker image (-Dnative.static=true, see Static and mostly-static Linux builds below). A plain dynamic-linked build - which is what you get on macOS, Windows, and any Linux build that doesn't pass-Dnative.static=true- needs no musl toolchain at all.
Building locally
./native/scripts/build-native.sh # host binary, with preflight checks
./native/scripts/build-native.sh --smoke # ... and run the smoke test against it
native/scripts/build-native.sh wraps the raw Maven command below with the three checks that turn
this build's cryptic failures into an immediate, named error: that JAVA_HOME/GRAALVM_HOME
really point at a GraalVM home with bin/native-image; that the builder is the version
native/pom.xml pins its Truffle artifacts to (the getLoopNodeFactory() skew below, which has
shipped to main twice); and, for a musl-static build, that the musl toolchain and its static
libz.a are in place before the link step rather than after several minutes of compilation. It
also picks the link mode CI uses for the host platform, and locates the produced binary the same
way the workflow does. Pass -- to forward extra arguments to Maven, or use the raw command:
mvn -Pnative -pl native -am -DskipTests package
native/pom.xml binds the native-maven-plugin's compile-no-fork goal to Maven's package
phase, so package is what actually invokes native-image and produces a binary.
mvn -Pnative -pl native -am -DskipTests compile only resolves and compiles the module (which has
no Java sources of its own) - the package-phase execution is never reached, so compile builds
nothing native and produces no binary. Use compile only as a fast dependency-resolution sanity
check (e.g. to confirm the module graph resolves before spending several minutes on an actual
build), never to validate that a native image builds successfully.
A successful build writes the executable to:
native/target/arcadedb-<version>-<os.detected.name>-<os.detected.arch>
using os-maven-plugin's detected OS/architecture
naming, e.g. arcadedb-26.8.1-osx-aarch_64 on Apple Silicon or arcadedb-26.8.1-linux-x86_64 on
Linux/amd64 (Windows appends .exe). Native Image cannot cross-compile: you get one binary for the
platform you build on, and building for another OS/architecture requires a runner or machine of
that platform (see Target matrix).
Static and mostly-static Linux builds
The two required Linux CI targets don't use a plain dynamic-linked build; they build binaries with no (or almost no) runtime dependency on shared libraries, so they can run in a minimal container base image with no libc/package manager of its own:
# linux/amd64: fully static, musl libc - runs on `FROM scratch`
mvn -Pnative -pl native -am -DskipTests -Dnative.static=true package
# linux/arm64: static except glibc itself - runs on a small glibc base (distroless)
mvn -Pnative -pl native -am -DskipTests -Dnative.mostlystatic=true package
-Dnative.static=true activates native/pom.xml's native-static profile, which appends
--static --libc=musl to the native-image build args, producing a binary with zero dynamic
library dependencies (verifiable with ldd reporting "not a dynamic executable"). GraalVM Native
Image only supports --static against musl libc, not glibc - glibc's NSS mechanism requires
dlopen at runtime, which is incompatible with static linking.
Building --static --libc=musl requires a musl toolchain with a musl-built static zlib on its
library path (the JDK's zip/net code links libz), following GraalVM's own
"Static and Mostly-Static Images"
guide: install musl-tools/musl-dev, then build zlib from source with
CC=<triplet>-linux-musl-gcc ./configure --static --includedir=/usr/include/<triplet>-linux-musl --libdir=/usr/lib/<triplet>-linux-musl. The include/lib directories matter: musl-gcc's generated
specs file restricts header/library search to that triplet-specific directory and does not fall
back to /usr/include//usr/lib, so installing zlib to a plain --prefix=/usr leaves it invisible
to the musl toolchain and the native-image -lz link step fails. .github/workflows/native-image.yml's
"Set up musl toolchain" step implements this end to end and only runs on the linux/amd64 leg (see
below for why arm64 skips it).
linux/arm64 cannot use the same fully-static musl build. GraalVM CE's linux-aarch64 release
tarball ships lib/static/linux-aarch64/glibc but not lib/static/linux-aarch64/musl (the
linux-amd64 tarball ships both), so --static --libc=musl fails at the link stage with "Missing
libraries: java, nio, net, zip" on arm64 regardless of how the musl toolchain is set up - a known,
long-standing GraalVM CE gap on aarch64 (oracle/graal#4645,
closed "not planned"). -Dnative.mostlystatic=true uses GraalVM's documented middle ground for this
case instead: -H:+StaticExecutableWithDynamicLibC links everything statically except glibc, which
stays dynamically loaded at runtime (matching glibc's own NSS/dlopen requirement). No musl
toolchain is needed for this build mode.
Neither of these flags affects Netty's DNS resolution: io.netty.resolver.dns is not on the
classpath of any module wired into native/pom.xml (the wire-protocol modules that do use Netty
only build server-side Bootstraps to accept inbound connections, and grpc-client, the one module
that dials out, is not part of the native build). No -Dio.netty.resolver.dns.* runtime flag is
needed or recommended for this image.
Feature set
Included and working in the native binary:
- SQL and OpenCypher query engines
- HTTP API and Studio
- Wire protocols: PostgreSQL, Redis, MongoDB, Neo4j Bolt, gRPC
- GraalJS scripting - embedded in the image and functional (see the JS scripting caveat below for the one thing to be aware of)
- The
consoleandgraphqlmodules
Deliberately excluded from the native build (native/pom.xml's dependency list omits them):
- Gremlin (Apache TinkerPop) - not on the native module's classpath
arcadedb-tracing(OpenTelemetry) - not on the native module's classpath
If your deployment needs Gremlin or OpenTelemetry tracing, use the JVM distribution instead.
Target matrix
.github/workflows/native-image.yml builds three targets. Native Image cannot cross-compile, so
each target is built on a runner of that same OS/architecture:
| Target | Runner | Status |
|---|---|---|
| linux/amd64 | ubuntu-latest | Required - fully static (musl) |
| linux/arm64 | ubuntu-24.04-arm | Required - mostly static (glibc) |
| windows/amd64 | windows-latest | Best-effort |
The two Linux targets are required: true in the CI matrix and gate the workflow; windows/amd64 is
required: false (continue-on-error), so a failure there does not redden the run.
macOS is a local build, not a CI target. No free hosted runner can produce a macOS native image
here: GitHub retired the free macos-13 (Intel) runner and the only remaining x64 macOS label
(macos-15-intel) is a paid "Larger Runners" tier not enabled here; and the free macos-15 (arm64,
~7 GB RAM) cannot build this image without thrashing swap for 40+ minutes, because native-image
wants roughly 80% of system RAM and this build's image heap is ~630 MB. Build the macOS arm64 binary
locally instead - it is fast on a real Mac (mvn -Pnative -pl native -am -DskipTests package, a few
minutes). There is likewise no free Windows-on-ARM runner.
Docker images
Only the two required Linux targets are packaged into Docker images, because only those two produce binaries with no runtime dependency on the host's shared libraries - a prerequisite for a minimal, mostly-empty container base image. The two targets use different container bases, because they reach "no shared libraries needed" by different build modes (see Static and mostly-static Linux builds above):
| Arch | Build mode | Dockerfile | Base image |
|---|---|---|---|
| linux/amd64 | fully static (musl) | native/src/main/docker/Dockerfile.native.scratch | FROM scratch |
| linux/arm64 | mostly static (glibc) | native/src/main/docker/Dockerfile.native.distroless | FROM gcr.io/distroless/base-debian12:nonroot |
Both Dockerfiles COPY in the binary, config/ (staged from package/src/main/config), and expose
the same port/volume surface as the JVM image's Dockerfile, scoped to the modules the native build
actually bundles - notably no port 8182 (Gremlin is excluded): 2480 (HTTP/Studio), 2434
(Raft gRPC/replication), 5432 (Postgres), 6379 (Redis), 27017 (MongoDB), 7687 (Bolt),
and 50051 (gRPC). Volumes: config, databases, backups, replication, log.
A few things differ from the JVM Docker image because neither base has a shell:
- No settings-reading wrapper script. The JVM image's
bin/server.shexpands$ARCADEDB_SETTINGS(and$JAVA_OPTS) onto the java command line; these images have no shell to run such a script, so bothENTRYPOINTs are exec-form arrays that invoke the binary directly. Extra-Dflags must be passed as trailing arguments todocker runinstead of via-e ARCADEDB_SETTINGS=...- see Running the container below. - User model differs per base.
scratchhas no/etc/passwdand noadduser, so that image runs as UID 0 unless the caller overrides it withdocker run --user <uid>:<gid>. The distroless:nonroottag bakes in anonroot:nonroot(65532:65532) user, so the arm64 image already runs non-root by default with no override needed. - CA trust bundle.
scratchhas no trust store of any kind, which breaks outbound HTTPS (import, replication, MCP-client code paths) unless one is supplied; the scratch DockerfileCOPYs in aca-certificates.crtbundle for that. The distroless base already bundles glibc, libssl, and a CA bundle, so no separateCOPYis needed there. Either way, this is a partial fix: GraalVM Native Image bakes a snapshot of the build machine's JDK cacerts trust store into the image at build time by default, and copying in an OS-level CA bundle does not change the JVM's baked-in default trust anchors - it only supplies a trust store for code paths that read the OS bundle explicitly.
Tags
Images are published as arcadedata/arcadedb:<version>-native-amd64 and
arcadedata/arcadedb:<version>-native-arm64 (per-arch), stitched into a combined
arcadedata/arcadedb:<version>-native multi-arch manifest via docker buildx imagetools create.
<version> is the plain Maven project.version with no v prefix (e.g. 26.8.1), matching this
repository's release-tag convention.
Publishing only happens on a published GitHub release: native-image.yml has no push:
trigger, only workflow_dispatch and release: [published], so an ordinary branch push never
reaches the Docker jobs. A manual workflow_dispatch run still builds and smoke-tests both images
(--load into the runner's local Docker daemon) without logging into Docker Hub or pushing anything,
so the Dockerfiles can be validated end to end from any branch without publishing under
arcadedata/arcadedb.
Building the images locally
./native/scripts/build-native-docker.sh # host arch, build + smoke, no push
./native/scripts/build-native-docker.sh --port 2481 # ... if something already holds 2480
./native/scripts/build-native-docker.sh --skip-binary-build # iterate on the Dockerfiles only
native/scripts/build-native-docker.sh produces the same per-arch image the workflow publishes,
from your working tree, without a registry. It does in one pass what CI splits across the build
and docker jobs: build a throwaway builder image
(native/src/main/docker/Dockerfile.native-builder) carrying the pinned GraalVM CE builder and -
for amd64 - the musl toolchain with its musl-built static zlib; run the ordinary Maven native
build inside it with the repository bind-mounted, so the Linux binary lands in native/target
on the host; stage the build context the way the workflow's "Stage build context" step does; build
the runtime image with buildx --load; then run exercise.sh against the container and scan its
startup log, the same two assertions the workflow's "Smoke the container" step makes.
The container step is what makes this work on a machine that is not Linux at all. Native Image cannot cross-compile, so a macOS host has no other way to produce the Linux binary these images need. Both runtime Dockerfiles are used unmodified, so what you get is the shipped image rather than a local approximation; only the builder image is local-only, and it is never published.
Give Docker at least 12 GiB of memory (Docker Desktop: Settings -> Resources -> Memory; CI's
Linux runners have 16 GiB). native-image sizes its build heap at ~80% of the memory it can see,
and inside a container that is the Docker VM's allocation, not the host's - so a 36 GiB Mac left on
Docker Desktop's 8 GiB default still fails, with a Java heap OutOfMemoryError about 21 minutes in,
once the whole Maven reactor has already built. This image embeds GraalJS, which is why it is so
hungry: native-image.yml had to move its macOS leg onto a paid larger runner for the same reason.
The script checks this up front rather than letting you discover it 21 minutes later;
--builder-memory <size> caps the builder heap explicitly, and --allow-low-memory skips the check.
The GraalVM download URL is resolved at build time from the graal-<version> release matching
native/pom.xml's native.graalvm.version, so the builder cannot drift from the pin the way a
hardcoded URL would - see that property's comment for why that matters. The Maven repository and
the ./mvnw distribution are cached in ~/.cache/arcadedb-native-m2 (override with
ARCADEDB_NATIVE_M2), so only the first run pays for populating them.
The default architecture is the host's. Building the other one needs --allow-emulation and runs
the entire native-image build under QEMU, which takes hours and often runs out of memory - that is
why CI's docker job gives each architecture its own native runner instead of one runner with
buildx --platform. The image is tagged arcadedb:<version>-native-<arch>-local by default, which
deliberately is not a publishable arcadedata/arcadedb name.
Running the container
docker run --rm -p 2480:2480 -p 5432:5432 \
--user 1000:1000 \
-v "$(pwd)/databases:/home/arcadedb/databases" \
arcadedata/arcadedb:<version>-native \
-Darcadedb.server.rootPassword=PlayWithData123!
--user 1000:1000is only meaningful (and only necessary) for thelinux/amd64(scratch) image; thelinux/arm64(distroless) image already runs as its baked-in non-root UID by default. Whatever UID you choose should own the host-sidedatabasesbind mount (orchownthe directory to match).- Map whichever wire-protocol ports you actually enabled in addition to
2480-5432(Postgres),6379(Redis),27017(MongoDB),7687(Bolt),50051(gRPC). - Both
ENTRYPOINTs already bake in two runtime defaults ahead of any arguments you pass:-Dorg.jline.terminal.dumb=true(the server runs non-interactively in a container, so it avoids JLine's JNI-backed interactive terminal provider) and-Djava.util.logging.config.file=/home/arcadedb/config/arcadedb-log.properties(JUL logging configuration, matching the JVM image's setup). - Setting the root password: because neither base image has a shell, the JVM image's
-e ARCADEDB_SETTINGS="-Darcadedb.server.rootPassword=..."pattern does not work here - there is no shell to expand$ARCADEDB_SETTINGSinto the command line. Instead, pass-Darcadedb.server.rootPassword=...(and any other-Dflags) as trailing arguments todocker run, as in the example above; Docker appends them after theENTRYPOINTarray, and the binary parses them exactly like JVM system properties.
Known limitations and caveats
JS scripting is included, but with a security caveat
GraalJS is embedded in the native binary and works correctly - there is no fallback error and no missing language. It runs through the SVM-integrated Truffle runtime built into the image, not the plain HotSpot Truffle runtime the JVM build uses.
Getting there required relaxing a Truffle safety check. By default, native-image refuses to embed
Truffle languages whose builtins reach call targets Truffle's partial-evaluation blocklist considers
unsafe to compile ahead of time. Six GraalJS builtins - Atomics.* and TypedArray.prototype.set -
reach java.lang.invoke.MethodHandle.linkToStatic, which trips that blocklist and aborts the build.
native/pom.xml passes -H:-TruffleCheckBlockListMethods -H:-TruffleCheckBlackListedMethods to let
the build proceed with JS embedded, at the cost of those specific builtins being unverified under
Truffle's own soundness check once their call targets get hot enough to runtime-compile.
This is a real, reachable residual risk, not a theoretical one. GraalPolyglotEngine's sandboxing
(deny Class/ClassLoader/reflection, IOAccess.NONE, allowNativeAccess(false),
allowCreateProcess(false), allowEnvironmentAccess(NONE), allowCreateThread(false),
allowPolyglotAccess(NONE)) restricts Java/host interop, not the JS language surface - Atomics,
SharedArrayBuffer, and TypedArray.prototype.set are standard ECMAScript globals, so none of that
sandboxing touches them. Testing directly against the native binary confirmed all of them are
reachable through an ordinary {"language":"js", ...} command:
| Script | Result |
|---|---|
typeof Atomics | "object" |
var a = new Int32Array(4); Atomics.add(a, 0, 5); a[0] | 5 |
var b = new Uint8Array(4); b.set([1,2,3],0); b[1] | 2 |
typeof SharedArrayBuffer | "function" |
var sab = new SharedArrayBuffer(16); var ia = new Int32Array(sab); Atomics.store(ia, 0, 99); Atomics.load(ia, 0) | 99 |
Atomics.wait(...) | TypeError: Unsupported operation (blocked cleanly by allowCreateThread(false); the server stayed up) |
So: any authenticated caller with query/command permissions can reach Atomics.add/store/load,
SharedArrayBuffer construction, and TypedArray.prototype.set through a plain
{"language":"js"} command or a polyglot SQL function. Atomics.wait is the one operation that is
not reachable in practice, because it requires real thread blocking that allowCreateThread(false)
denies. Functional testing here did not turn up a crash or unsound result from the other builtins,
but a functional smoke test cannot validate Truffle's own soundness concern under sustained or
adversarial load once those call targets get hot enough for runtime compilation. This is documented
here as an accepted, non-blocking risk for this experimental add-on, not something the JS regression
gate in native/src/test/scripts/exercise.sh can detect.
The native image is NOT production-hardened for multi-tenant or untrusted JS. Because any
authenticated caller can reach the relaxed-blocklist builtins above, do not expose the native
build's {"language":"js"} surface to untrusted callers in a shared/multi-tenant deployment until
this is hardened. Hardening options for a future non-experimental promotion: make GraalJS an opt-in
piece of the native build (excluded by default, enabled by a build flag) so an operator who does not
need JS carries neither the unverified AOT paths nor the extra image size, and/or pin the
native-image builder to a Truffle version that keeps the blocklist enforced.
Binary size
The native binary is currently large - around 732 MiB - mostly because native/pom.xml passes
-H:IncludeResources=.* to the build, which embeds every classpath resource (Studio's web assets,
config templates, Lucene codec files, etc.) into the image. This is deliberately broad for
correctness first; tightening -H:IncludeResources to the specific resource patterns each embedded
module actually needs at runtime is a known follow-up, not something this experimental add-on has
done yet.
JVector and SIMD
The build passes --add-modules=jdk.incubator.vector so the JDK's Vector API module is available in
the image, and JVector (ArcadeDB's vector-index library) can load and run. Whether native-image
generates the same SIMD-intrinsic code paths the JIT produces on the JVM, or falls back to scalar
execution for some of those operations, has not been rigorously benchmarked here. Treat this as a
possible performance gap under the native image, not a correctness concern - vector search results
should be identical either way.
CA trust nuance
See CA trust bundle above: GraalVM Native Image bakes a snapshot of the build machine's JDK cacerts trust store into the image at build time, and neither Dockerfile's CA bundle changes that baked-in default - it only supplies an OS-level trust store for code paths that read it explicitly.
Postgres wire protocol: verified in CI, not on every dev machine
The Postgres-wire smoke check in native/src/test/scripts/exercise.sh needs a psql client to run
as a hard assertion. CI installs postgresql-client on the linux/amd64 leg specifically for this,
so a real Postgres-wire round trip against the native binary is verified there. It has not been
verified against a macOS development machine that lacks psql - on such a machine the check
WARN-skips instead of failing, so a missing local psql will not block a local build.
Building the reachability metadata
The build's native-maven-plugin configuration enables GraalVM's
reachability metadata repository (Netty,
gRPC, Undertow, Jackson, HdrHistogram, Apache HttpClient) alongside a hand-generated
reachability-metadata.json produced by running the assembled JVM server under the native-image
tracing agent (native/src/test/scripts/trace.sh) while exercising the smoke-test surface. This
combination has been sufficient so far - no manual edits to the generated JSON have been required
when adding new smoke-tested surface. Regenerate it with trace.sh if you add a code path that
reflects, serializes, or loads resources in a way the current metadata doesn't cover; the script
needs GraalVM's own java (point JAVA_HOME at it, same requirement as above) because it launches
the server with -agentlib:native-image-agent.