Build a static native Micronaut image on FROM scratch
Contents
This builds a Micronaut 4.10.21 service into a statically linked (musl) GraalVM native executable inside Docker and ships it in a FROM scratch image with nothing else in it; the end state is a container that starts in 24 ms and answers /health.
- Tested on
- Fedora Linux 44, kernel 7.2.10-200.fc44.x86_64, Docker Engine 29.9.1 (rootful), builder
ghcr.io/graalvm/native-image-community:21-muslib-ol9(GraalVM CE 21.0.2), Micronaut 4.10.21 (2026-10-20). - Not verified
- GraalVM 25 musl builders, TLS calls out of the scratch image (no CA bundle), a non-root
USER, stripping the binary. - Needs
- x86_64 Linux with Docker; about 5 GB of free RAM for
native-image(peak RSS 4.22 to 4.55 GB here); network access tolaunch.micronaut.io,ghcr.ioand Maven Central;curl,unzip,file,ss. - Time
- 15 min. The build itself took 58 s with a cached Maven layer and 1m42s cold; the one-time pull of the builder image (531MB compressed) was not timed separately; the rest is reading and typing.
- Risk
- none to the host. The manual creates one image and one container bound to
127.0.0.1:8080; Undo removes both.
Before you start#
- No placeholders. Every name below is literal: project
demo-service, imagedemo-service:static, containerdemo-static. Run Step 1 in an empty directory; every later command runs insidedemo-service/. - Docker is up and rootful. A rootless daemon lists
name=rootlessunder security options.This manual was not run on rootless Docker or Podman.$ docker info --format '{{.ServerVersion}} {{.OperatingSystem}} {{.Architecture}} {{.SecurityOptions}}' 29.9.1 Fedora Linux 44 (KDE Plasma Desktop Edition) x86_64 [name=seccomp,profile=builtin name=cgroupns] - Enough memory.
native-imageused 4.22 to 4.55 GB peak RSS across two builds of this project.$ free -g | head -2 total used free shared buff/cache available Mem: 30 4 5 0 21 25 - The builder image runs and is the version you expect. The first run also prints pull progress.
$ docker run --rm ghcr.io/graalvm/native-image-community:21-muslib-ol9 --version native-image 21.0.2 2024-01-16 GraalVM Runtime Environment GraalVM CE 21.0.2+13.1 (build 21.0.2+13-jvmci-23.1-b30) Substrate VM GraalVM CE 21.0.2+13.1 (build 21.0.2+13, serial gc) - Nothing to back up, but nothing to overwrite either. Check that the tag doesn't exist yet and port 8080 is free.Expected: header lines only.
$ docker image ls demo-service; ss -ltn "sport = :8080" IMAGE ID DISK USAGE CONTENT SIZE EXTRA State Recv-Q Send-Q Local Address:Port Peer Address:Port
Steps#
Generate the project from Micronaut Launch, with the
managementfeature for/health.$ curl -s -o demo-service.zip 'https://launch.micronaut.io/create/default/com.example.demo-service?lang=JAVA&build=MAVEN&test=JUNIT&javaVersion=JDK_21&features=management' && unzip -q demo-service.zip && ls demo-service aot-jar.properties micronaut-cli.yml mvnw mvnw.bat pom.xml README.md srcExpected: a Maven project with
mvnw. Thencd demo-service; every later command runs there.Pin the Micronaut platform to 4.10.21. Launch generated 5.2.2 on 2026-10-20; this manual is verified on 4.10.21. The version sits on two lines: the parent and
micronaut.version.$ grep -n '5\.2\.2' pom.xml 13: <version>5.2.2</version> 20: <micronaut.version>5.2.2</micronaut.version> $ sed -i 's|5\.2\.2|4.10.21|g' pom.xml && grep -n '4\.10\.21' pom.xml 13: <version>4.10.21</version> 20: <micronaut.version>4.10.21</micronaut.version>Expected: the same two lines, now
4.10.21. If Launch prints another version, replace that one instead.Add a controller that reports the VM it runs on.
cat > src/main/java/com/example/HelloController.java <<'EOF' package com.example; import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Get; @Controller("/hello") public class HelloController { @Get public String hello() { return "vm=" + System.getProperty("java.vm.name"); } } EOF$ ls src/main/java/com/example Application.java HelloController.javaWrite the Dockerfile and
.dockerignore. The builder is themuslibvariant, which ships the musl toolchain.-Dpackaging=native-imagemakes Maven build the executable, and-DbuildArgs=--static,--libc=muslreachesnative-imageas two separate options. The runtime stage holds nothing but the binary.cat > Dockerfile <<'EOF' FROM ghcr.io/graalvm/native-image-community:21-muslib-ol9 AS builder WORKDIR /app COPY .mvn/ .mvn/ COPY mvnw pom.xml ./ RUN ./mvnw -q dependency:resolve COPY src/ src/ RUN ./mvnw package -Dpackaging=native-image -DskipTests \ -DbuildArgs=--static,--libc=musl FROM scratch COPY --from=builder /app/target/demo-service /demo-service EXPOSE 8080 ENTRYPOINT ["/demo-service"] EOF printf 'target/\n' > .dockerignore$ ls -A aot-jar.properties Dockerfile .dockerignore .gitignore micronaut-cli.yml .mvn mvnw mvnw.bat pom.xml README.md srcBuild the image.
$ time docker build --progress=plain -t demo-service:static . … #11 13.39 GraalVM Native Image: Generating 'demo-service' (static executable)... #11 14.95 Java version: 21.0.2+13, vendor version: GraalVM CE 21.0.2+13.1 #11 14.95 C compiler: x86_64-linux-musl-gcc (linux, x86_64, 10.2.1) #11 14.95 Garbage collector: Serial GC (max heap size: 80% of RAM) … #11 54.18 Produced artifacts: #11 54.18 /app/target/demo-service (executable) #11 54.18 Finished generating 'demo-service' in 41.5s. #11 54.80 [INFO] BUILD SUCCESS #11 DONE 54.9s … #13 naming to docker.io/library/demo-service:static done … real 0m57.948sExpected:
(static executable)andx86_64-linux-musl-gccin the header. Plain(executable)withgcc (redhat, …)means a dynamic glibc binary that won't start on scratch (Failure modes, row 1). This run reused a cached Maven layer (#9 CACHED); a cold-cache build takes longer, see Field notes.Check the image size.
$ docker image ls demo-service:static IMAGE ID DISK USAGE CONTENT SIZE EXTRA demo-service:static 54933f63f033 97.3MB 24.4MBExpected: roughly these numbers. On Docker 29 DISK USAGE counts the binary plus CONTENT SIZE:
docker image inspect --format '{{.Size}}'gave 97,325,596 bytes, the binary is 72,879,936 bytes (Verify), and the difference, 24,445,660 bytes, is the 24.4MB in CONTENT SIZE.Start the container.
$ docker run -d --name demo-static -p 127.0.0.1:8080:8080 demo-service:static 891b261e529be8ce923b8df27d55cc5c2599fd7a97edc022a2945c177a8abf3c $ sleep 2; docker logs demo-static 2>&1 | grep -o 'Startup completed in .*' Startup completed in 24ms. Server Running: http://891b261e529b:8080Expected: a startup line in tens of milliseconds. An
exec /demo-service: no such file or directoryinstead is row 1 of Failure modes.
Verify#
- The service answers, and it runs as a native image (
Substrate VM), not on a JVM:$ curl -s localhost:8080/health; echo; curl -s localhost:8080/hello; echo {"status":"UP"} vm=Substrate VM - The binary is statically linked:
$ docker cp -q demo-static:/demo-service ../demo-service.bin && file ../demo-service.bin && stat -c %s ../demo-service.bin ../demo-service.bin: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, with debug_info, not stripped 72879936 - The image holds nothing else; there is no shell to exec into:This also means a
$ docker exec demo-static /bin/sh OCI runtime exec failed: exec failed: unable to start container process: exec: "/bin/sh": stat /bin/sh: no such file or directoryHEALTHCHECK CMD curl …cannot work in this image (nocurl); probe/healthfrom outside.
Undo / rollback#
- Remove the container and the image:Expected: no container name and an empty image list.
$ docker rm -f demo-static && docker rmi demo-service:static demo-static Untagged: demo-service:static Deleted: sha256:54933f63f033209ca1b038431cae4224528dac0a1563633797cb396724034a64 $ docker ps -a --filter name=demo-static --format "{{.Names}}"; docker image ls demo-service IMAGE ID DISK USAGE CONTENT SIZE EXTRA - The builder image (
21-muslib-ol9: DISK USAGE 1.75GB, CONTENT SIZE 531MB here) and the BuildKit cache with the Maven layer stay on disk.docker rmi ghcr.io/graalvm/native-image-community:21-muslib-ol9anddocker builder pruneremove them[UNVERIFIED: not exercised on this box]. Delete the project directory anddemo-service.binby hand.
Failure modes#
| Symptom (exact message) | Cause | Fix |
|---|---|---|
exec /demo-service: no such file or directory, exit 255, on docker run | Binary built without --static --libc=musl (here: 21-ol9 builder, no buildArgs). file reports dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2; scratch has no such loader, and the missing file in the message is that interpreter, not /demo-service. | Use the -muslib builder and -DbuildArgs=--static,--libc=musl (Step 4). GraalVM does not support static linking against glibc. |
/bin/sh: line 1: gu: command not found, exit 127 | RUN gu install native-image from older GraalVM setup instructions. GraalVM for JDK 21 removed the GraalVM Updater. | Delete the line. The native-image-community images already contain native-image. |
./mvnw package -Pnative -DskipTests ends in BUILD SUCCESS, but target/ holds only demo-service-0.1.jar (and original-demo-service-0.1.jar). In a Dockerfile the next COPY --from=builder /app/target/<name> fails with "/app/target/<name>": not found | In micronaut-parent 4.10.21 the native profile only drives native test goals; it does not switch the packaging. | Build with -Dpackaging=native-image (Step 4). |
Error: In user 'G1' is not a valid value for the option --gc. Supported values are 'epsilon', 'serial'. (exit 20) | --gc=G1 passed to GraalVM Community Edition. G1 in native images is Oracle GraalVM only. | Drop --gc=G1; CE builds use Serial GC. |
/demo-service: /lib64/libc.so.6: version `GLIBC_2.32' not found (required by /demo-service) (and the same for GLIBC_2.34), exit 1 | A dynamic binary from the 21-ol9 builder (glibc 2.34) run on ubi8/ubi-minimal (glibc 2.28). | Build static for scratch as in this manual; for a dynamic binary, run on a base with glibc 2.34 or newer (ubi9/ubi-minimal worked in the first pass, see Field notes). |
Field notes#
- 2026-10-20 (re-run), Fedora 44 / kernel 7.2.10-200.fc44.x86_64, Docker 29.9.1 rootful,
21-muslib-ol9@sha256:9c27f6de343c…(GraalVM CE 21.0.2), Micronaut 4.10.21 (core artifacts 4.10.31), micronaut-maven-plugin 4.11.6, native-maven-plugin 0.11.3, Maven 3.9.16:- Three full builds of the same service: the first from the earlier session's project copy, then two from fresh Launch zips. All produced a static executable that started in 23 to 25 ms and served
/healthand/hellofrom scratch. The steps above are the third run, executed in order in one directory. - Cold-cache run (second build, from a fresh Launch zip; Maven layer not cached, builder already pulled): 1m42s total,
dependency:resolve43.5 s,native-image40.8 s, peak RSS 4.55 GB. The Steps run (third build) reused that Maven layer: 58 s total,native-image41.5 s, peak RSS 4.22 GB. -DbuildArgs=--static,--libc=muslwas the open question: the plugin'sbuildArgsis a list parameter, and the comma split it correctly. Thenative-imagecommand line in the Maven log shows--static --libc=muslas two arguments after--no-fallback.- Reproduced every row of Failure modes on purpose:
-Pnativeand the dynamic build against this service,guand--gc=G1directly in the builder image, the GLIBC error by running the dynamic binary onubi8/ubi-minimal. The dynamic counterfactual (same Dockerfile,21-ol9builder, nobuildArgs) built cleanly and only failed atdocker run; nothing in the build log warns that the target will be scratch. - The first pass's 18.8MB image held a one-class program, not this service; it does not compare with the 97.3MB / 24.4MB here.
- The
21-muslib-ol9tag still points at GraalVM CE 21.0.2 (image created 2024-01-16); the ghcr tag list has no newer 21.x musl tag.
- Three full builds of the same service: the first from the earlier session's project copy, then two from fresh Launch zips. All produced a static executable that started in 23 to 25 ms and served
- 2026-10-20 (first pass), same box and builder:
- Started from a plain one-class Java jar, not Micronaut:
native-image --static --libc=musl --no-fallback -jarin the21-muslib-ol9builder, copied into scratch. Image 18.8MB, ran and printedjava.vm=Substrate VM; 74 s including the builder pull,native-image16 s. The same Dockerfile with the21-ol9builder and no--staticbuilt in 20 s and died withexec ./app: no such file or directory, exit 255. - Then tested two Micronaut Dockerfiles taken from existing docs, both with glibc runtime bases. Both used
-Pnativeand got only a jar. The second passed-Dmicronaut.native.args="--gc=G1 …", a property neither micronaut-parent nor native-maven-plugin defines: the build log still saidSerial GC, so it was silently ignored (the plugin's property isbuildArgs). The second also ran onubi8/ubi-minimaland hit the GLIBC error;ubi9/ubi-minimalfixed it. ItsHEALTHCHECKprobed/actuator/health, a Spring path that is 404 on Micronaut, so the container wentunhealthy;/healthcomes frommicronaut-management. BothCOPYed anapplication.ymlthat Launch doesn't generate (it writesapplication.properties).
- Started from a plain one-class Java jar, not Micronaut:
Notes#
- Newer builders: the ghcr tag list carries
25-muslib-ol9and25iN-…-muslib-ol9tags. Not tested here. A 25i4 bump changed native-image behaviour for one Micronaut service: see Health 200, API 200, Prometheus 000. - Pin the builder by digest (
FROM ghcr.io/graalvm/native-image-community:21-muslib-ol9@sha256:9c27f6de343cc74e0e20db1a682405424ef18c3431c08bdf7ccf6dc64ce5be60), the digest this build resolved.[UNVERIFIED: not built with the pinned FROM line] - What scratch leaves out: no CA certificates (outbound TLS), no
/etc/passwd(the process runs as uid 0 unless you set a numericUSER), no time zone data, no shell. None of these was needed for/health, and none was tested. - Binary size:
filereportswith debug_info, not stripped. Stripping was not tried. - JVM alternative: the first pass also ran the jar on
eclipse-temurin:21-jre(455MB image, G1, heap honoured the cgroup limit). For how native and JVM compare under a memory cap, see Native Image vs JVM at 128 MB; for native-image metadata on Micronaut 5, see Micronaut 5 Native Image: 14 Entries vs 1,913.
Filed under: java, micronaut, graalvm, native-image, docker
Sources#
- GraalVM: Build a Statically Linked or Mostly-Statically Linked Native Executable:
--static --libc=musl, glibc static linking unsupported, target is a scratch container. - GraalVM for JDK 21 release notes: "Removed GraalVM Updater, gu".
- GraalVM Native Image memory management: Serial is the default; G1 is not available in Community Edition.
- Micronaut Maven plugin: packaging:
mvn package -Dpackaging=native-imagedelegates to the GraalVM native Maven plugin. - GraalVM Native Build Tools, Maven plugin:
<buildArgs>takes one native-image option per entry. - graalvm/container README:
native-image-communitytag scheme,-muslibfor native image only.