# Build a static native Micronaut image on FROM scratch

> Compile a Micronaut 4 service to a musl-static GraalVM native executable and ship it in a FROM scratch image that answers /health.

Published 2026-11-08. HTML version: https://guides.tsvinc.dev/static-native-micronaut-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`.

> **Scope** — Verified 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.
  ```console
  $ 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.
  ```console
  $ 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.
  ```console
  $ 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.
  ```console
  $ 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`.**
   ```console
   $ 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`.
   ```console
   $ 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.**
   ```bash
   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
   ```
   ```console
   $ 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.
   ```bash
   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
   ```
   ```console
   $ ls -A
   aot-jar.properties
   Dockerfile
   .dockerignore
   .gitignore
   micronaut-cli.yml
   .mvn
   mvnw
   mvnw.bat
   pom.xml
   README.md
   src
   ```

5. **Build the image.**
   ```console
   $ 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.**
   ```console
   $ 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.**
   ```console
   $ 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:
  ```console
  $ curl -s localhost:8080/health; echo; curl -s localhost:8080/hello; echo
  {"status":"UP"}
  vm=Substrate VM
  ```
- The binary is statically linked:
  ```console
  $ 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:
  ```console
  $ 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:
  ```console
  $ 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) | 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 `/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 `COPY`ed 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](https://tsvinc.dev/posts/native-prometheus-hang-graalvm-25i4/).
- **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](https://tsvinc.dev/posts/native-vs-jvm-128mb/); for native-image metadata on Micronaut 5, see [Micronaut 5 Native Image: 14 Entries vs 1,913](https://tsvinc.dev/posts/micronaut-5-native-image/).

## Sources

- [GraalVM: Build a Statically Linked or Mostly-Statically Linked Native Executable](https://www.graalvm.org/latest/reference-manual/native-image/guides/build-static-executables/): `--static --libc=musl`, glibc static linking unsupported, target is a scratch container.
- [GraalVM for JDK 21 release notes](https://www.graalvm.org/release-notes/JDK_21/): "Removed GraalVM Updater, gu".
- [GraalVM Native Image memory management](https://www.graalvm.org/latest/reference-manual/native-image/optimizations-and-performance/MemoryManagement/): Serial is the default; G1 is not available in Community Edition.
- [Micronaut Maven plugin: packaging](https://micronaut-projects.github.io/micronaut-maven-plugin/latest/examples/package.html): `mvn package -Dpackaging=native-image` delegates to the GraalVM native Maven plugin.
- [GraalVM Native Build Tools, Maven plugin](https://graalvm.github.io/native-build-tools/latest/maven-plugin.html): `<buildArgs>` takes one native-image option per entry.
- [graalvm/container README](https://github.com/graalvm/container): `native-image-community` tag scheme, `-muslib` for native image only.

