Manual

Build a static native Micronaut image on FROM scratch

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 to launch.micronaut.io, ghcr.io and 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, image demo-service:static, container demo-static. Run Step 1 in an empty directory; every later command runs inside demo-service/.
  • Docker is up and rootful. A rootless daemon lists name=rootless under security options.
    $ 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]
    
    This manual was not run on rootless Docker or Podman.
  • Enough memory. native-image used 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.
    $ 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
    
    Expected: header lines only.

Steps#

  1. Generate the project from Micronaut Launch, with the management feature 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
    src
    

    Expected: a Maven project with mvnw. Then cd demo-service; every later command runs there.

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

  3. 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.java
    
  4. Write the Dockerfile and .dockerignore. The builder is the muslib variant, which ships the musl toolchain. -Dpackaging=native-image makes Maven build the executable, and -DbuildArgs=--static,--libc=musl reaches native-image as 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
    src
    
  5. Build 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.948s
    

    Expected: (static executable) and x86_64-linux-musl-gcc in the header. Plain (executable) with gcc (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.

  6. Check the image size.

    $ docker image ls demo-service:static
    IMAGE                 ID             DISK USAGE   CONTENT SIZE   EXTRA
    demo-service:static   54933f63f033       97.3MB         24.4MB        
    

    Expected: 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.

  7. 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:8080
    

    Expected: a startup line in tens of milliseconds. An exec /demo-service: no such file or directory instead 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:
    $ 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 directory
    
    This also means a HEALTHCHECK CMD curl … cannot work in this image (no curl); probe /health from outside.

Undo / rollback#

  • Remove the container and the image:
    $ 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
    
    Expected: no container name and an empty image list.
  • 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-ol9 and docker builder prune remove them [UNVERIFIED: not exercised on this box]. Delete the project directory and demo-service.bin by hand.

Failure modes#

Symptom (exact message)CauseFix
exec /demo-service: no such file or directory, exit 255, on docker runBinary 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 127RUN 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 foundIn 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 1A 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 /health and /hello from 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:resolve 43.5 s, native-image 40.8 s, peak RSS 4.55 GB. The Steps run (third build) reused that Maven layer: 58 s total, native-image 41.5 s, peak RSS 4.22 GB.
    • -DbuildArgs=--static,--libc=musl was the open question: the plugin's buildArgs is a list parameter, and the comma split it correctly. The native-image command line in the Maven log shows --static --libc=musl as two arguments after --no-fallback.
    • Reproduced every row of Failure modes on purpose: -Pnative and the dynamic build against this service, gu and --gc=G1 directly in the builder image, the GLIBC error by running the dynamic binary on ubi8/ubi-minimal. The dynamic counterfactual (same Dockerfile, 21-ol9 builder, no buildArgs) built cleanly and only failed at docker 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-ol9 tag still points at GraalVM CE 21.0.2 (image created 2024-01-16); the ghcr tag list has no newer 21.x musl tag.
  • 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 -jar in the 21-muslib-ol9 builder, copied into scratch. Image 18.8MB, ran and printed java.vm=Substrate VM; 74 s including the builder pull, native-image 16 s. The same Dockerfile with the 21-ol9 builder and no --static built in 20 s and died with exec ./app: no such file or directory, exit 255.
    • Then tested two Micronaut Dockerfiles taken from existing docs, both with glibc runtime bases. Both used -Pnative and 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 said Serial GC, so it was silently ignored (the plugin's property is buildArgs). The second also ran on ubi8/ubi-minimal and hit the GLIBC error; ubi9/ubi-minimal fixed it. Its HEALTHCHECK probed /actuator/health, a Spring path that is 404 on Micronaut, so the container went unhealthy; /health comes from micronaut-management. Both COPYed an application.yml that Launch doesn't generate (it writes application.properties).

Notes#

  • Newer builders: the ghcr tag list carries 25-muslib-ol9 and 25iN-…-muslib-ol9 tags. 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 numeric USER), no time zone data, no shell. None of these was needed for /health, and none was tested.
  • Binary size: file reports with 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#