JAVA_OPTS_APPEND values dropping through the floor when JAVA_OPTS is set, illustrating the bash fallback expansion

TL;DR

On Red Hat UBI Java images (we hit it on ubi9/openjdk-25, but the behaviour is the same across the family), setting JAVA_OPTS silently discards JAVA_OPTS_APPEND. The variable name is misleading. Both variables can be set, both will show up in env, but only one of them ends up on the java command line. The other is dropped without a warning.

We hit this while debugging an unrelated AOT cache crash in a Quarkus app. The crash was a different problem, but the env-var trap was right there in the same image, and worth its own writeup.

What we saw

Both env vars were set in the pod. JAVA_OPTS from the deployment, JAVA_OPTS_APPEND from the Dockerfile.jvm template that Quarkus' Maven plugin generates:

$ kubectl exec ... -- sh -c 'env | grep ^JAVA_'
JAVA_OPTS=-XX:InitialRAMPercentage=60 ... -XX:AOTCache=/deployments/app.aot ...
JAVA_OPTS_APPEND=-Dquarkus.http.host=0.0.0.0 ... -XX:AOTCache=/deployments/app.aot
JAVA_APP_JAR=/deployments/quarkus-run.jar

What's on the actual java process, though, is just JAVA_OPTS. Everything from JAVA_OPTS_APPEND is gone:

$ kubectl exec ... -- sh -c 'tr "\0" " " </proc/1/cmdline; echo'
java -XX:InitialRAMPercentage=60 ... -XX:AOTCache=/deployments/app.aot
     -Dio.netty.machineId=00:00:00:00:00:01 -Xlog:gc*:stdout:time,level,tags
     -Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=...
     -cp . -jar /deployments/quarkus-run.jar

(The -Dquarkus.http.host=0.0.0.0 at the end is there because the same flag was also in JAVA_OPTS. JAVA_OPTS_APPEND wasn't appending anything: it was just being ignored.)

Why this happens

Red Hat UBI's run-java.sh (the standard entrypoint for ubi9/openjdk-25 and friends) builds the effective options like this:

# /opt/jboss/container/java/run/run-java.sh
get_java_options() {
  ...
  opts=${JAVA_OPTS-${debug_opts} ${proxy_opts} ${jvm_opts} ${JAVA_OPTS_APPEND}}
  echo "${opts}" | awk '$1=$1'
}

That is the bash parameter expansion ${VAR-default}. Use the value of VAR if it's set; otherwise use default. The moment JAVA_OPTS is set to anything (including the empty string), the entire fallback branch is dropped on the floor. That branch is where debug_opts, proxy_opts, jvm_opts, and JAVA_OPTS_APPEND all live.

So the name JAVA_OPTS_APPEND is a fallback, not an append. It only gets consulted when JAVA_OPTS is genuinely undefined. Set both, and only JAVA_OPTS takes effect.

This wasn't always the behaviour

Red Hat's own knowledge base notes that version 1.15 of ubi8/openjdk-17 changed how JAVA_OPTS interacts with JAVA_OPTS_APPEND. Before that, setting both meant your appends actually got appended. After 1.15, the override-not-append behaviour we just described became the default. UBI 9 inherited it. As of ubi9/openjdk-25 in May 2026, the behaviour is the same.

That's worth knowing because if you migrated an older Dockerfile forward and didn't change either variable, you may have lost flags somewhere along the way. The env will still list both. The JVM will only see one.

The naming maze

Just to make this more fun, there are three variable names floating around the ecosystem, and they don't all mean the same thing:

If you're moving between WildFly-era Keycloak, Quarkus-era Keycloak, Spring Boot images, and plain UBI Java images, you may genuinely have to check each one to know which variable does what.

The second silent failure mode

The trap above assumes you're using run-java.sh as your entrypoint. If your image bypasses it (custom Dockerfile that calls java directly, like a slim image you built yourself), none of these environment variables are honoured at all. JAVA_OPTS, JAVA_OPTIONS, JAVA_OPTS_APPEND, all of them. They're inert.

This is harder to spot because nothing breaks visibly. The JVM just runs with whatever's on the CMD line of the Dockerfile, and any flags you thought you were passing via env vars are silently absent.

30-second detection

You don't have to read run-java.sh to find out. Compare what's in env against what's actually on the java command line:

kubectl exec <pod> -- sh -c 'env | grep ^JAVA_'
kubectl exec <pod> -- sh -c 'tr "\0" " " </proc/1/cmdline; echo'

Any flag that's in an env var but missing from /proc/1/cmdline is a flag the JVM never saw. If JAVA_OPTS and JAVA_OPTS_APPEND both contain things and only JAVA_OPTS made it to the command line, you've hit the trap.

For local Docker, swap kubectl exec for docker exec and you get the same answer.

What to do instead

Pick one variable. Don't set both.

  • If you want the image's defaults plus a couple of your own flags, set JAVA_OPTS_APPEND only. Leave JAVA_OPTS unset entirely. The fallback path runs, defaults apply, your additions land.
  • If you want full control over the JVM command line, set JAVA_OPTS with everything you need. Accept that you've replaced the image's default tuning (memory ratios, GC defaults, etc.) and that JAVA_OPTS_APPEND won't be consulted.
  • If you're not on run-java.sh, neither of these helps. Put your flags on the CMD/ENTRYPOINT directly, or wrap the image with a small launcher script that consumes the env var explicitly.

The thing not to do is set both and assume they'll combine. The name tells you they will. The shell doesn't agree.

References

The article that surfaced this for us digs into the JEP 483 AOT cache portability trap on JDK 25. Different problem, same image.

Running Java on UBI images at scale? Talk to us about managed Java hosting where we've already walked into the traps.