Developer Tools Azure

cloudimg OpenJDK 21 Dev Kit on Ubuntu 24.04 on Azure User Guide

| Product: cloudimg OpenJDK 21 Dev Kit on Ubuntu 24.04 LTS on Azure

Overview

This guide covers the deployment and use of the cloudimg OpenJDK 21 Dev Kit on Ubuntu 24.04 on Azure from the cloudimg Azure Marketplace images.

This image is a development workstation, not a server. You connect to it over SSH and use it to write, build, test and package Java applications. It runs no network service of any kind: after first boot the only listening socket reachable from outside the machine is SSH on port 22. There is no web console, no dashboard and no login page, and consequently no credential of any kind ships in the image — there is nothing to log in to and nothing to rotate.

The primary toolchain is OpenJDK 21 LTS, installed from the Ubuntu 24.04 main pocket. That choice is deliberate and it is the most important thing to understand about this image:

  • The Ubuntu OpenJDK build is GPL-2.0 with the Classpath Exception, which is what makes it redistributable inside a commercial machine image. Oracle JDK is not and is never installed here.
  • Because the JDK comes from main, it is covered by noble-security and is upgraded automatically by the unattended-upgrades this image leaves enabled. Your JVM keeps receiving CVE fixes for the life of the machine without you doing anything.
  • Nothing is held back or version pinned. No apt-mark hold, no Pin: version anywhere. A common way to "pin" a toolchain is to freeze its package, and that silently blocks every future security update — this image deliberately does not do that.

Pinning is instead handled by verification and assertion: at build time every installed OpenJDK .deb had its SHA-256 compared against the digest Ubuntu publishes in its GPG signed Packages index, the resolved version was recorded, and java -version was asserted to report that exact build. The recorded pin lives in /usr/share/doc/cloudimg-openjdk/BUILD-PIN on the machine.

Where your work lives. A dedicated 32 GiB data volume is attached at /srv/javadata and carries the Maven local repository, GRADLE_USER_HOME and a workspace directory. Dependency caches grow to gigabytes on a real project, so keeping them off the operating system disk lets you size, snapshot and grow that storage independently. Note the mount point is /srv/javadata and not /mnt — on Azure, /mnt is where the ephemeral resource disk is mounted, and anything cached there is lost when the VM is deallocated.

What is included

  • OpenJDK 21 LTS (the default java and javac) from the Ubuntu 24.04 main pocket, GPL-2.0 with the Classpath Exception, checksum verified at build time

  • OpenJDK 17 LTS as a secondary toolchain, from the same pocket under the same licence, for projects that still target 17

  • Apache Maven 3.9.16 from the official Apache archive, verified against the SHA-512 Apache publishes

  • Gradle 9.6.1 from the official gradle.org archive, verified against the SHA-256 Gradle publishes

  • A dedicated 32 GiB data volume at /srv/javadata holding the Maven local repository, GRADLE_USER_HOME and a workspace directory, mounted by UUID so it survives reboots and device renames

  • cloudimg-java-selftest, a single command that resolves a real dependency from Maven Central, runs a JUnit 5 suite, packages a runnable jar, executes it and checks the values it computed

  • A working example project at /usr/local/share/cloudimg-java/selftest — a multi module-ready Maven and Gradle project with a real dependency and a JUnit 5 suite, ready to copy into your workspace as a starting point

  • A global Maven toolchains file registering both JDKs, so a project can select either one

  • The complete third party licence bundle for both JDKs, Maven and Gradle — including the whole of each JDK's legal/ tree and the GPL-2 body the Ubuntu copyright file refers to — under /usr/share/doc/cloudimg-openjdk/licenses/

  • Ubuntu 24.04 LTS, fully patched at build time with unattended-upgrades left enabled

Prerequisites

  • An Azure subscription with permission to create virtual machines
  • An SSH key pair (ssh-keygen -t ed25519 if you do not already have one)
  • A VM size with at least 2 vCPUs and 4 GiB of memory. Standard_B2s is the recommended size and is what this image is tested on; step up to Standard_D2s_v3 or larger if you build big multi module projects
  • Outbound HTTPS access, so Maven and Gradle can reach Maven Central

Deploying the image

Create the VM from the Marketplace offer, choosing an administrator username and supplying your SSH public key. The image declares its own 32 GiB data disk, so you do not need to attach one: it is provisioned, formatted and mounted at /srv/javadata automatically.

Inbound rules only need SSH on port 22, ideally restricted to your own address. This product exposes nothing else, so there is no other port to open.

Connecting

Connect over SSH using the administrator account you chose:

ssh azureuser@<vm-ip>

First boot runs a one shot setup service that resolves the administrator account you created, grants it access to the data volume, points ~/.m2/repository and ~/.gradle at that volume, and writes a plain toolchain manifest. It finishes in a couple of seconds. You can confirm it ran:

sudo systemctl is-active openjdk-firstboot.service
cat /var/lib/cloudimg/openjdk-toolchain.env

The manifest is not a secrets file — this product has no secrets. It simply records what is installed and where:

JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
JAVA_HOME_17=/usr/lib/jvm/java-17-openjdk-amd64
JDK_PRIMARY_VERSION=21.0.11+10-1~24.04.2
JDK_SECONDARY_VERSION=17.0.19+10-1~24.04.2
MAVEN_VERSION=3.9.16
GRADLE_VERSION=9.6.1
MAVEN_LOCAL_REPOSITORY=/srv/javadata/m2/repository
GRADLE_USER_HOME=/srv/javadata/gradle
WORKSPACE=/srv/javadata/workspace
WORKSPACE_OWNER=azureuser

Verifying the machine in one command

Before you trust the machine with your own code, run the self test. It is not a version check: it writes a real multi class Maven project, resolves a third party dependency from Maven Central, runs a JUnit 5 suite, packages a runnable jar, executes it and checks the values the program computed, builds the same sources again with Gradle, and proves cross target compilation by reading the class file version back out of the emitted bytecode.

cloudimg-java-selftest

It takes about ninety seconds on a Standard_B2s the first time, because it is genuinely downloading dependencies. Every line should read PASS and the last line should be SELFTEST_OK.

Terminal showing cloudimg-java-selftest completing every stage: the pinned OpenJDK 21.0.11 toolchain and the secondary OpenJDK 17, the Maven and Gradle caches resolving onto the dedicated data volume, commons-codec resolved from Maven Central, all four JUnit 5 tests passing, a packaged runnable jar executing and computing 17984 primes below 200000 summing to 1709600813 together with the expected commons-codec digest, Gradle producing the same verified result, javac emitting Java 21 and Java 17 bytecode as class file majors 65 and 61, and the attack surface check confirming that port 22 answers with an SSH banner while ports 8080, 8443 and 9000 are refused, ending in SELFTEST_OK

Note the last section of that output. The listener check uses a positive control: it first proves the probe can detect a live service by reading the real SSH-2.0- banner from port 22, and only then reports that the same probe finds ports 8080, 8443 and 9000 refused. A refusal on its own would prove nothing, because a broken probe also fails to connect.

The toolchain

java -version
mvn -v
gradle -v

mvn and gradle are installed as wrappers in /usr/local/bin, which is on the default PATH. That matters more than it sounds: it means ssh yourvm 'mvn -v' works, and so does a systemd unit or a cron job, not only an interactive login shell.

Terminal showing java -version reporting OpenJDK 21.0.11 built by Ubuntu, the secondary OpenJDK 17.0.19 toolchain, Apache Maven 3.9.16 resolving its Java runtime to the OpenJDK 21 home, Gradle 9.6.1, and the recorded BUILD-PIN file listing both JDK package versions, the Ubuntu main pocket as the source under GPL-2.0 with Classpath Exception, and the note that each package was SHA-256 matched against the digest in Ubuntu's GPG signed Packages index

The exact build that shipped in your image is recorded on the machine:

cat /usr/share/doc/cloudimg-openjdk/BUILD-PIN

If a security update has since raised the JDK version, that is working as intended — the recorded pin tells you what shipped, and apt tells you what you are running now.

Your first project

A complete, working example project ships inside the image at /usr/local/share/cloudimg-java/selftest. It is the same project the self test builds: a small multi class Maven and Gradle project with a real third party dependency and a JUnit 5 suite. Copy it into your workspace and you have something that already builds:

cd /srv/javadata/workspace
rm -rf example
cp -a /usr/local/share/cloudimg-java/selftest example
cd example
mvn -q package
java -jar target/cloudimg-java-selftest-1.0.0.jar

Expected output — these are values the program computes, not strings it prints:

SELFTEST-COMPUTE PRIMES=17984 SUM=1709600813
SELFTEST-DEP SHA256=e05cd5dad95983cfe3d1ecbd2ef2b608a6af56c7f8cca140aadf9921c6cfbf80
SELFTEST-RUNTIME OpenJDK 64-Bit Server VM 21.0.11 classfile-target=uk.co.cloudimg.selftest.Main

The second line is produced by Apache Commons Codec, a dependency Maven resolved from Maven Central into the cache on your data volume, so seeing it proves dependency resolution, compilation, packaging and execution all worked end to end.

Targeting Java 17

Both LTS releases are installed, so you can compile for 17 while running the build on 21. The simplest way is the compiler release property, and you can confirm it worked by reading the class file version out of the compiled bytecode rather than trusting the build log:

cd /srv/javadata/workspace/example
mvn -q -Dmaven.compiler.release=17 clean package -DskipTests
javap -v -cp target/classes uk.co.cloudimg.selftest.Primes | grep 'major version'

Expected output — 61 is the class file major version for Java 17, where Java 21 emits 65:

  major version: 61

For multi module builds that need a whole separate JDK rather than a different bytecode target, both JDKs are registered in a global Maven toolchains file at /etc/cloudimg/maven-toolchains.xml, which the mvn wrapper passes to every build. Declare maven-toolchains-plugin in your POM and select <version>17</version> to have Maven run the compiler from the OpenJDK 17 installation itself.

Starting a project from scratch

When you want your own project rather than a copy of the example, this is the minimum that builds and runs against a real dependency:

sudo install -d -o "$(id -un)" -g "$(id -gn)" /srv/javadata/workspace/hello/src/main/java/com/example
cd /srv/javadata/workspace/hello
cat > pom.xml <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>hello</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
      <version>3.17.0</version>
    </dependency>
  </dependencies>
</project>
XML
cat > src/main/java/com/example/Hello.java <<'JAVA'
package com.example;

import org.apache.commons.lang3.StringUtils;

public class Hello {
    public static void main(String[] args) {
        String name = args.length > 0 ? args[0] : "world";
        System.out.println(StringUtils.capitalize(name) + " from Java "
                + Runtime.version().feature());
    }
}
JAVA

Build it and run it. The dependency is fetched from Maven Central on the first build and cached on the data volume afterwards:

cd /srv/javadata/workspace/hello
mvn -q package
LANG3="$(find /srv/javadata/m2/repository -name 'commons-lang3-3.17.0.jar' | head -1)"
java -cp "target/classes:${LANG3}" com.example.Hello cloudimg

Expected output:

Cloudimg from Java 21

Building with Gradle

Gradle 9.6.1 is installed and uses the same JDK. GRADLE_USER_HOME already points at the data volume, and first boot also symlinks the administrator's ~/.gradle there — which is what matters when a project ships its own ./gradlew wrapper, because the wrapper never sees the /usr/local/bin wrapper script.

gradle --version

Where your work lives

findmnt -no SOURCE,TARGET,FSTYPE,SIZE /srv/javadata
df -h /srv/javadata
grep '<localRepository>' /opt/apache-maven-3.9.16/conf/settings.xml

Terminal showing the dedicated data volume mounted at /srv/javadata as an ext4 filesystem of 31.2G, its UUID keyed nofail entry in fstab, the Maven global settings file pointing localRepository at /srv/javadata/m2/repository, findmnt confirming that path resolves to the /srv/javadata mount rather than the operating system disk, the cache directories owned by root and the javadev group with inheritable access control entries, and df showing the data volume separate from the root filesystem

The Maven redirection is done in Maven's global settings.xml rather than through an environment variable. That is deliberate: it applies to every user, to non interactive SSH commands, and to a project's own ./mvnw wrapper, none of which reliably inherit a shell profile.

To grow the volume, resize the managed disk in Azure and then extend the filesystem:

sudo resize2fs /dev/disk/azure/scsi1/lun0

Remote development with VS Code

This image is built to work with VS Code Remote-SSH on first connect. Add the machine to your SSH config, connect, and install the Extension Pack for Java in the remote window. The extension pack detects JAVA_HOME, finds mvn and gradle on the default PATH, and downloads and manages its own language server inside ~/.vscode-server.

That last point is why no system wide Java language server is installed here. The editor extension ships and updates its own copy, so a second system copy would never be used — and the one commonly packaged for this purpose is licensed under the EPL, which would attach a source availability obligation to the image for no functional benefit. What the extension actually needs from the host is a JDK 21 or newer with JAVA_HOME set and build tools on the PATH, and that is exactly what this image provides.

Licences and attack surface

Every upstream licence, notice and legal file shipped by both JDKs, by Maven and by Gradle is copied verbatim into the image, including the whole of each JDK's legal/ tree and the GPL-2 body that the Ubuntu copyright file refers to:

sed -n '1,20p' /usr/share/doc/cloudimg-openjdk/licenses/NOTICE
find /usr/share/doc/cloudimg-openjdk/licenses -type f | wc -l
grep -m1 'License:.*Classpath' /usr/share/doc/openjdk-21-jre-headless/copyright
ss -Hltn

Terminal showing the shipped notice file naming OpenJDK 21 LTS as GNU General Public License version 2 with the Classpath Exception and pointing at the legal tree and Debian copyright artifacts, a count of 104 licence files in the image, the Ubuntu copyright file's own declaration of GPL with Classpath exception, the opening paragraphs of the OpenJDK assembly exception stating that the code is distributed under GPL2 with a special exception, and ss confirming the only listening sockets are SSH on port 22 and the loopback DNS stub resolver

The ss output is the whole attack surface: SSH on port 22, plus systemd-resolved's stub resolver bound to loopback addresses that nothing outside the machine can reach.

Dependency caches ship empty. The self test resolves around a hundred third party jars when it runs, and none of them are in the image you received — they were purged before capture. That keeps cloudimg from redistributing artifacts it never licensed for redistribution, and it means the first build on a fresh machine downloads its own dependencies, which is ordinary Java behaviour.

Keeping the machine patched

unattended-upgrades is enabled, so Ubuntu security updates — including OpenJDK ones — are applied automatically. To check or apply updates by hand:

sudo apt-get update
apt list --upgradable

Because nothing in this image is held or version pinned, a JDK security update installs normally. After a JDK upgrade the reported build in java -version will move ahead of the value recorded in BUILD-PIN; that is the expected and desirable outcome.

Troubleshooting

mvn or gradle cannot download dependencies. The machine needs outbound HTTPS to repo.maven.apache.org and, for Gradle, services.gradle.org. Check the network security group and any firewall or proxy on the path.

A build is slow or runs out of memory on Standard_B2s. Two vCPUs and 4 GiB is enough for the self test and for small to medium projects, but a large multi module build wants more. Resize the VM rather than adding swap — this image deliberately ships with no swap, because Azure certification rejects an image with swap baked into the operating system disk, and Azure manages swap on the ephemeral resource disk instead.

~/.m2/repository looks like a symlink. It is. First boot points it at the data volume so that tools reading the conventional path still land on the right storage.

A build wrote to the operating system disk instead of the data volume. Check findmnt -no TARGET -T "$(mvn help:evaluate -Dexpression=settings.localRepository -q -DforceStdout)". It should print /srv/javadata. If a project overrides localRepository in its own settings.xml or passes -Dmaven.repo.local, that override wins.

The self test reports a broken check. Read the line: it names the specific stage that did not pass. The most common cause on a fresh machine is no outbound network, which shows up as the Maven stage not completing.

Support

cloudimg provides 24/7 support for this image. Java is a registered trademark of Oracle and/or its affiliates, and OpenJDK is used here nominatively under Oracle's OpenJDK trademark policy. Gradle is a trademark of Gradle, Inc., and Apache and Apache Maven are trademarks of The Apache Software Foundation. cloudimg is not affiliated with, endorsed by, or sponsored by Oracle, Gradle, Inc., The Apache Software Foundation or Canonical Ltd.