Your First Open Liberty Application with Maven
Start with an empty folder and build, test, hot-reload, and package a small Jakarta REST application with Maven.
I went looking for one current guide that starts with an empty directory and ends with a tested Open Liberty application built by Maven. I found all the pieces, spread across several places. The Maven plugin documentation explains goals and parameters. Dev mode has its own page. Feature installation has another. The WebSphere Liberty download page introduces a second set of Maven coordinates. Each page explains its part, but the complete path is still missing.
The Liberty web application archetype looks like the obvious shortcut. I generated it and checked the result. Its latest published version is still 3.7.1, and it creates a project with Java 7 compiler settings, javax.servlet, JUnit 4, and an older Liberty Maven Plugin. A new Jakarta EE 11 application needs a different starting point.
So we will build the project directly. The application stays small: one REST endpoint and one integration test. We will run it in Liberty dev mode, test it against a real server with ./mvnw verify, and package the same server as a runnable JAR. Maven downloads the runtime and its features, so the local setup only needs a JDK and Maven.
Keep four parts in mind as we work: the Java API used for compilation, the Liberty runtime, the server features that implement the API, and the Maven phases that assemble and test the application. All four must agree. The API controls what the code can compile. The server features control what Liberty can run. The Maven phases decide when everything is assembled and tested.
What You Need
We use Java 21, Open Liberty 26.0.0.6, Jakarta REST 4.0, and Liberty Maven Plugin 3.12.0. Java 21 gives us a current LTS baseline and meets the Java 17 minimum for Jakarta EE 11 features. Open Liberty also supports other Java releases; the Open Liberty Java support documentation lists the current combinations.
JDK 21
Maven 3.9 or later
curlor another HTTP clientAbout ☕️☕️
Create the Project
Begin with an empty directory and create the Maven layout or clone the folder from my Github repository:
mkdir -p getting-started/src/main/java/dev/mainthread \
getting-started/src/main/liberty/config \
getting-started/src/test/java/dev/mainthread
cd getting-startedAdd the following pom.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>dev.mainthread</groupId>
<artifactId>getting-started</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>war</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<openliberty.version>26.0.0.6</openliberty.version>
<liberty.var.http.port>9080</liberty.var.http.port>
<liberty.var.https.port>9443</liberty.var.https.port>
</properties>
<dependencies>
<dependency>
<groupId>jakarta.ws.rs</groupId>
<artifactId>jakarta.ws.rs-api</artifactId>
<version>4.0.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.14.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<failOnMissingWebXml>false</failOnMissingWebXml>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.4</version>
<configuration>
<systemPropertyVariables>
<liberty.http.port>${liberty.var.http.port}</liberty.http.port>
</systemPropertyVariables>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>io.openliberty.tools</groupId>
<artifactId>liberty-maven-plugin</artifactId>
<version>3.12.0</version>
<configuration>
<runtimeArtifact>
<groupId>io.openliberty</groupId>
<artifactId>openliberty-kernel</artifactId>
<version>${openliberty.version}</version>
<type>zip</type>
</runtimeArtifact>
<serverName>gettingStartedServer</serverName>
</configuration>
<executions>
<execution>
<id>create-server</id>
<phase>prepare-package</phase>
<goals>
<goal>create</goal>
<goal>install-feature</goal>
</goals>
</execution>
<execution>
<id>deploy-application</id>
<phase>package</phase>
<goals>
<goal>deploy</goal>
</goals>
</execution>
<execution>
<id>package-server</id>
<phase>package</phase>
<goals>
<goal>package</goal>
</goals>
<configuration>
<include>runnable</include>
</configuration>
</execution>
<execution>
<id>start-server</id>
<phase>pre-integration-test</phase>
<goals>
<goal>test-start</goal>
</goals>
</execution>
<execution>
<id>stop-server</id>
<phase>post-integration-test</phase>
<goals>
<goal>test-stop</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>Now generate a Maven wrapper. This keeps the Maven version with the project:
mvn wrapper:wrapper -Dmaven=3.9.16From here, use ./mvnw on macOS or Linux and mvnw.cmd on Windows.
The jakarta.ws.rs-api dependency gives the compiler the Jakarta REST types. Its scope is provided because Liberty supplies the implementation at runtime. We add only the REST API. If the code uses another Jakarta EE API, we must add its API dependency and the matching Liberty feature. This application uses annotations and has no web.xml, so the WAR plugin sets failOnMissingWebXml to false.
The runtimeArtifact block selects Open Liberty and pins its version. Maven expands the kernel under target/liberty, and install-feature adds the features declared in server.xml. Fixed plugin and runtime versions make the build repeatable.
The execution blocks connect Liberty to the Maven lifecycle. During prepare-package, Liberty creates the server and installs its features. The package phase builds and deploys the WAR, then creates a runnable JAR. The integration-test phases start the server, run Failsafe, and stop the server again.
Failsafe needs the HTTP port as well. Dev mode provides server properties when it runs the tests itself. During a normal ./mvnw verify, the Failsafe configuration passes liberty.http.port to the test.
Choose the Open Liberty runtime
The coordinates are easy to mix up. This project uses io.openliberty:openliberty-kernel. The current WebSphere Liberty developer download page lists com.ibm.websphere.appserver.runtime:wlp-kernel for WebSphere Liberty. The names look similar, but they select different distributions with different licenses. The runtimeArtifact block makes our choice explicit.
Configure the Server
Add src/main/liberty/config/server.xml:
<server description="Getting started server">
<featureManager>
<platform>jakartaee-11.0</platform>
<feature>restfulWS</feature>
</featureManager>
<variable name="http.host" defaultValue="localhost"/>
<variable name="http.port" defaultValue="9080"/>
<variable name="https.port" defaultValue="9443"/>
<httpEndpoint id="defaultHttpEndpoint"
host="${http.host}"
httpPort="${http.port}"
httpsPort="${https.port}"/>
<webApplication location="getting-started.war" contextRoot="/"/>
</server>The platform entry sets the Jakarta EE version for feature names that have no version. Here, restfulWS resolves to Jakarta REST 4.0. You can check that mapping in the Jakarta REST 4.0 feature reference.
The HTTP endpoint listens on localhost for local development. Its port values match the Maven properties. When the plugin starts Liberty, it writes the Maven values into a configuration drop-in. The POM, server, and integration test now use the same port setting.
The webApplication entry deploys getting-started.war at the root context. The Jakarta REST application adds /api, and the resource adds /hello. Together they create the /api/hello URL.
Add the REST Endpoint
The REST application needs a base path. Create src/main/java/dev/mainthread/HelloApplication.java:
package dev.mainthread;
import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;
@ApplicationPath("/api")
public class HelloApplication extends Application {
}@ApplicationPath registers the Jakarta REST application and sets /api as its base path. A resource-level @Path only adds to that base path, so we need this class or an equivalent servlet mapping.
Now add src/main/java/dev/mainthread/HelloResource.java:
package dev.mainthread;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/hello")
public class HelloResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String hello() {
return "Liberty is running.";
}
}The resource adds /hello below /api, so its full path is /api/hello. Liberty supplies the Jakarta REST implementation. Our WAR only contains the application code.
Run in Dev Mode
Start Liberty in dev mode:
./mvnw liberty:devThe first run downloads the kernel and the features needed by restfulWS, so it takes longer than later starts. Wait until the console shows:
[INFO] ************************************************************************
[INFO] * Liberty is running in dev mode.
[INFO] * Automatic generation of features: [ Off ]
[INFO] * h - see the help menu for available actions, type 'h' and press Enter.
[INFO] * q - stop the server and quit dev mode, press Ctrl-C or type 'q' and press Enter.
[INFO] * Liberty server port information:
[INFO] * Liberty server HTTP port: [ 9080 ]
[INFO] * Liberty debug port: [ 7777 ]
[INFO] ************************************************************************Keep dev mode running. Open another terminal and call the endpoint:
curl http://localhost:9080/api/helloExpected response:
Liberty is running.The Liberty dev goal runs create, install-feature, and deploy before starting the server. After startup, it watches Java sources, test sources, resources, dependencies, and Liberty configuration. Leave the Maven process running while you work in the editor.
Add an Integration Test
We want the test to call the running server over HTTP. Add src/test/java/dev/mainthread/HelloResourceIT.java:
package dev.mainthread;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.Test;
class HelloResourceIT {
@Test
void returnsGreeting() throws Exception {
String port = System.getProperty("liberty.http.port");
if (port == null) {
throw new IllegalStateException("The Liberty Maven plugin did not provide liberty.http.port");
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/api/hello"))
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertEquals("Liberty is running.", response.body());
}
}Failsafe runs classes whose names end in IT. Surefire handles unit-test names such as *Test. This test calls the real server and checks the HTTP status and response body. It reads the port from Maven, so an override such as ./mvnw verify -Dliberty.var.http.port=9081 changes both the server and the test.
Press Enter in the dev-mode terminal to run the test. You should see:
Running dev.mainthread.HelloResourceIT
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0Now change the return value in HelloResource.hello() to Liberty reloads changes. and call the endpoint again. Dev mode recompiles the class, and the request returns the new text. Press Enter once more. The test still expects the original response, so it fails with this mismatch:
expected: <Liberty is running.> but was: <Liberty reloads changes.>Change the response back to Liberty is running. before continuing. The failed test confirms the full loop: dev mode deployed the change, the HTTP request reached it, and Failsafe checked the running application.
Verify the Complete Maven Lifecycle
Stop dev mode with Ctrl-C. Then run the build from a clean target directory:
./mvnw clean verifyThe validated build printed these lines:
The following features have been installed: ... restfulWS-4.0 ...
Server gettingStartedServer package complete in target/getting-started.jar.
Server gettingStartedServer started with process ID ...
Running dev.mainthread.HelloResourceIT
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Server gettingStartedServer stopped.
BUILD SUCCESSMaven runs the work in this order:
prepare-package— Create the Liberty server and install features fromserver.xmlpackage— Build and deploy the WAR, then package the server as a runnable JARpre-integration-test— Start Liberty and wait for the ready messageintegration-test— RunHelloResourceITwith Failsafepost-integration-test— Stop Libertyverify— Fail the build if Failsafe recorded a test failure
Use ./mvnw clean verify in CI as well. It starts with an empty target directory, tests the assembled server, and stops Liberty when the build completes.
Run the Packaged Server
The build creates two artifacts. target/getting-started.war contains the application. target/getting-started.jar contains the WAR, server configuration, Open Liberty kernel, and installed features.
Check that both files exist:
test -f target/getting-started.war \
&& test -f target/getting-started.jar \
&& echo "WAR and runnable JAR created"Expected output:
WAR and runnable JAR createdStart the packaged server:
java -jar target/getting-started.jarRun the same request from another terminal:
curl http://localhost:9080/api/helloExpected response:
Liberty is running.The runnable JAR extracts Liberty before startup. By default, it uses a wlpExtract directory under the user’s home directory. Set WLP_JAR_EXTRACT_DIR when you need a controlled extraction path. Set WLP_OUTPUT_DIR when logs must remain after Liberty removes the extracted runtime. The runnable JAR documentation explains both variables.
Keep the Boundaries Clear
The current server.xml is for local development. HTTP listens on localhost. There is no application authentication or production TLS. A container needs to accept traffic outside its own loopback interface, so pass the host variable when you run the JAR:
java -jar target/getting-started.jar --http.host='*'This host setting makes Liberty reachable outside the container. Authentication and transport security still belong in the deployment configuration before the application receives real traffic.
Feature installation follows two paths in this project. During development, ./mvnw liberty:dev calls install-feature and reacts when server.xml changes. During a normal Maven build, the prepare-package execution installs the features. You need direct ./mvnw liberty:install-feature calls or ESA dependencies when you manage user features, private feature repositories, or a separate server assembly. The plugin’s install-feature reference covers those cases.
Conclusion
We now have the complete path in one Maven project. The POM pins Open Liberty and connects it to the build lifecycle. server.xml selects the server capabilities. Dev mode and CI use the same application, configuration, and integration test. The runnable JAR packages the server we already verified.
It’s not half as convenient as getting started with Quarkus and the Quarkus CLI, but pretty decent level of configuration for a full blown Jakarta EE server.


