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.2 via graalvm/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.2
    

    native/pom.xml pins the GraalVM polyglot/Truffle artifacts (graal-sdk, polyglot, js-language, truffle-*, etc.) to the same 25.0.2 to 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.py enforces the equality in the always-on lint job. Move both sides in the same change, or neither.

    Being on Maven Central does not make a version installable. 25.0.3 and 25.0.4 publish the full set of org.graalvm.* artifacts but were never released as CE tarballs, so bumping native/pom.xml to 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:

    VersionCE tarballMaven artifacts
    25.0.2jdk-25.0.2yespinned
    25.0.3noneyes
    25.0.4noneyes
    25.1.3graal-25.1.3yesintermediate
    25.2.4graal-25.2.4yesintermediate, the previous pin

    The workflow selects the builder with setup-graalvm's java-version input, which fetches a jdk-<version> release and so reaches mainline builds only - which is the point. The other input, version, looks up graal-<version> first and falls back to jdk-<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, and java-version is 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.1 is reachable through neither input: node-semver rejects its four numeric components, so findLatestGraalVMCEVersion skips it as an "unexpected GraalVM CE release", finds no jdk-25.3.4.1 either, and throws. It has been pinned here by mistake once already.

  • JAVA_HOME (and GRAALVM_HOME) must point at the GraalVM home itself. The native-maven-plugin resolves the native-image builder from JAVA_HOME/GRAALVM_HOME, not by searching PATH for a native-image executable. A version-manager shim (jenv, sdkman shell shims, etc.) that only puts native-image on PATH is not sufficient - if JAVA_HOME still points at a different JDK, the plugin fails with native-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_HOME
    

    graalvm/setup-graalvm sets both variables automatically in CI.

  • musl-tools/musl-dev are only needed to build the fully-static linux/amd64 binary that feeds the scratch-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 console and graphql modules

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:

TargetRunnerStatus
linux/amd64ubuntu-latestRequired - fully static (musl)
linux/arm64ubuntu-24.04-armRequired - mostly static (glibc)
windows/amd64windows-latestBest-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):

ArchBuild modeDockerfileBase image
linux/amd64fully static (musl)native/src/main/docker/Dockerfile.native.scratchFROM scratch
linux/arm64mostly static (glibc)native/src/main/docker/Dockerfile.native.distrolessFROM 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.sh expands $ARCADEDB_SETTINGS (and $JAVA_OPTS) onto the java command line; these images have no shell to run such a script, so both ENTRYPOINTs are exec-form arrays that invoke the binary directly. Extra -D flags must be passed as trailing arguments to docker run instead of via -e ARCADEDB_SETTINGS=... - see Running the container below.
  • User model differs per base. scratch has no /etc/passwd and no adduser, so that image runs as UID 0 unless the caller overrides it with docker run --user <uid>:<gid>. The distroless :nonroot tag bakes in a nonroot:nonroot (65532:65532) user, so the arm64 image already runs non-root by default with no override needed.
  • CA trust bundle. scratch has no trust store of any kind, which breaks outbound HTTPS (import, replication, MCP-client code paths) unless one is supplied; the scratch Dockerfile COPYs in a ca-certificates.crt bundle for that. The distroless base already bundles glibc, libssl, and a CA bundle, so no separate COPY is 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:1000 is only meaningful (and only necessary) for the linux/amd64 (scratch) image; the linux/arm64 (distroless) image already runs as its baked-in non-root UID by default. Whatever UID you choose should own the host-side databases bind mount (or chown the 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_SETTINGS into the command line. Instead, pass -Darcadedb.server.rootPassword=... (and any other -D flags) as trailing arguments to docker run, as in the example above; Docker appends them after the ENTRYPOINT array, 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:

ScriptResult
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.