Open Liberty y Jakarta EE 11 en un único JAR ejecutable con Maven

Open Liberty permite empaquetar el servidor, la configuración y la aplicación en un único fichero que se arranca con java -jar. El resultado se parece mucho al «fat jar» de Spring Boot, pero con una diferencia de fondo: lo que corre dentro es un servidor de aplicaciones Jakarta EE completo, configurado a base de features en un server.xml. Desde la versión 26.0.0.5, Open Liberty soporta oficialmente Jakarta EE 11 (Platform, Web Profile y Core Profile), y la versión vigente a principios de noviembre de 2026 es la 26.0.0.11.

Este artículo monta desde cero una API REST con Jakarta REST 4.0, CDI 4.1 y JSON-B 3.0, la empaqueta con el liberty-maven-plugin en un JAR ejecutable y explica qué ocurre por debajo, cómo configurarlo y en qué casos tiene sentido elegirlo frente a Spring Boot. Si quieres situar primero el estándar, tienes un repaso en Jakarta EE 11 en 2026.

Qué hay dentro de un JAR ejecutable de Liberty

Un JAR ejecutable de Liberty contiene tres cosas: la instalación del runtime (reducida a lo necesario si se usa minify), el directorio del servidor con su server.xml y la aplicación ya desplegada. Al ejecutarlo, el JAR se extrae en una ubicación temporal y el servidor se lanza en primer plano. Por defecto esa ubicación es wlpExtract/<nombre del jar>_nnnnnnnnnnnnnnnnnnn dentro del directorio personal del usuario, y los ficheros de salida del servidor (logs, trabajo) se escriben ahí y se borran al parar el proceso.

Es una diferencia práctica respecto a Spring Boot, cuyo ejecutable usa su propio lanzador sobre el JAR anidado. En Liberty hay un paso de extracción en cada arranque, y por eso conviene controlar dónde se hace en entornos con el disco de solo lectura o con el directorio personal sin permisos de escritura. Más abajo veremos las variables de entorno que lo permiten.

Versiones que vamos a usar

Pieza

Versión

Nota

Open Liberty

26.0.0.11

Publicada en Maven Central a principios de noviembre de 2026

liberty-maven-plugin

3.12.5

Grupo io.openliberty.tools

Jakarta EE API

11.0.0

Artefacto jakarta.jakartaee-web-api

Java

17, 21, 25 o 27

Las versiones que la documentación de la feature webProfile-11.0 declara como soportadas; en el ejemplo se compila con 21

Estructura del proyecto

El proyecto es un WAR normal de Maven. Lo único especial es que el server.xml vive en src/main/liberty/config, que es el directorio donde el plugin lo busca.

tareas/
??? pom.xml
??? src/main/
    ??? java/com/example/tareas/
    ?   ??? Tarea.java
    ?   ??? TareaRepositorio.java
    ?   ??? TareaResource.java
    ?   ??? TareasApplication.java
    ??? liberty/config/
        ??? server.xml

El pom.xml

El plugin necesita cuatro objetivos encadenados. create instala el runtime y crea el servidor, install-feature instala las features que declara el server.xml, deploy copia el WAR al servidor y package genera el JAR. El objetivo package por sí solo no crea ni despliega nada: empaqueta lo que ya exista, así que hay que ejecutarlo después de los otros tres. Dentro de una misma ejecución, los objetivos se lanzan en el orden en que se declaran.

<?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>tareas</artifactId>
  <version>1.0.0</version>
  <packaging>war</packaging>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.release>21</maven.compiler.release>
    <liberty.version>26.0.0.11</liberty.version>
    <liberty.plugin.version>3.12.5</liberty.plugin.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>jakarta.platform</groupId>
      <artifactId>jakarta.jakartaee-web-api</artifactId>
      <version>11.0.0</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <build>
    <finalName>tareas</finalName>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-war-plugin</artifactId>
        <version>3.5.1</version>
      </plugin>

      <plugin>
        <groupId>io.openliberty.tools</groupId>
        <artifactId>liberty-maven-plugin</artifactId>
        <version>${liberty.plugin.version}</version>
        <configuration>
          <serverName>tareas</serverName>
          <runtimeArtifact>
            <groupId>io.openliberty</groupId>
            <artifactId>openliberty-runtime</artifactId>
            <version>${liberty.version}</version>
            <type>zip</type>
          </runtimeArtifact>
          <looseApplication>false</looseApplication>
        </configuration>
        <executions>
          <execution>
            <id>liberty-jar</id>
            <phase>package</phase>
            <goals>
              <goal>create</goal>
              <goal>install-feature</goal>
              <goal>deploy</goal>
              <goal>package</goal>
            </goals>
            <configuration>
              <include>minify,runnable</include>
              <packageType>jar</packageType>
              <packageName>tareas</packageName>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

Algunos detalles del pom.xml que merece la pena explicar:

  • jakartaee-web-api con ámbito provided: sirve para compilar, pero las clases las aporta Liberty en ejecución, así que no entran en el WAR.
  • runtimeArtifact apunta a io.openliberty:openliberty-runtime. Si no se indica nada, el plugin usa openliberty-kernel y descarga las features que haga falta en el paso install-feature.
  • looseApplication a false: una aplicación «suelta» (loose) apunta a las clases compiladas del proyecto en vez de copiar el WAR, y no sirve para distribuir un paquete.
  • include a minify,runnable: runnable genera el JAR, y minify conserva solo las partes del runtime que necesitan las features configuradas, lo que reduce bastante el tamaño. La combinación es válida y aparece en la documentación del comando server package.
  • finalName a tareas: así el WAR se llama tareas.war y coincide con el location del server.xml, sin número de versión.

Si trabajas con Maven a diario y dudas entre sus alternativas, en Maven y Gradle en 2026 comparamos las dos herramientas.

El server.xml: qué features cargar

La configuración de Liberty es composición: el servidor solo carga lo que declaras. Para una API REST con inyección de dependencias y JSON bastan tres features.

<server description="Servidor de tareas">

  <featureManager>
    <feature>restfulWS-4.0</feature>
    <feature>jsonb-3.0</feature>
    <feature>cdi-4.1</feature>
  </featureManager>

  <variable name="default.http.port" defaultValue="9080"/>
  <variable name="default.https.port" defaultValue="9443"/>

  <httpEndpoint id="defaultHttpEndpoint" host="*"
                httpPort="${default.http.port}"
                httpsPort="${default.https.port}"/>

  <webApplication location="tareas.war" contextRoot="/"/>
</server>

Hay tres formas de declarar las features, de más fina a más amplia:

  • Features sueltas (restfulWS-4.0, cdi-4.1, jsonb-3.0): el arranque es más rápido y, junto a minify, el JAR más pequeño. Es lo que recomiendo para un microservicio.
  • webProfile-11.0: activa todo el Web Profile de Jakarta EE 11. Según la documentación de Liberty incluye, entre otras, servlet-6.1, restfulWS-4.0, cdi-4.1, persistence-3.2, data-1.0, validation-3.1, faces-4.1, websocket-2.2, jsonb-3.0, jsonp-2.1 y concurrent-3.1.
  • jakartaee-11.0: la plataforma completa, que a su vez activa el Web Profile. Solo hace falta si usas piezas fuera de él, como mensajería o Batch.
<featureManager>
  <feature>webProfile-11.0</feature>
</featureManager>

Cambiar de una opción a otra no toca el código: solo ese bloque del server.xml. Por eso resulta cómodo empezar con webProfile-11.0 mientras el proyecto se define y afinar la lista de features al acercarse a producción.

La aplicación REST

El código es Jakarta EE estándar, sin una sola dependencia específica de Liberty. Empezamos por la clase que activa Jakarta REST bajo /api:

package com.example.tareas;

import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;

@ApplicationPath("/api")
public class TareasApplication extends Application {
}

El modelo es una clase con constructor vacío y accesores, el formato que JSON-B maneja sin configuración adicional:

package com.example.tareas;

public class Tarea {

    private long id;
    private String titulo;
    private boolean hecha;

    public Tarea() {
    }

    public Tarea(long id, String titulo, boolean hecha) {
        this.id = id;
        this.titulo = titulo;
        this.hecha = hecha;
    }

    public long getId() { return id; }
    public void setId(long id) { this.id = id; }

    public String getTitulo() { return titulo; }
    public void setTitulo(String titulo) { this.titulo = titulo; }

    public boolean isHecha() { return hecha; }
    public void setHecha(boolean hecha) { this.hecha = hecha; }
}

El repositorio en memoria es un bean CDI de ámbito de aplicación. Un mapa concurrente y un contador atómico bastan para el ejemplo; en un proyecto real aquí iría Jakarta Persistence o Jakarta Data.

package com.example.tareas;

import jakarta.enterprise.context.ApplicationScoped;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

@ApplicationScoped
public class TareaRepositorio {

    private final Map<Long, Tarea> tareas = new ConcurrentHashMap<>();
    private final AtomicLong secuencia = new AtomicLong();

    public List<Tarea> listar() {
        return new ArrayList<>(tareas.values());
    }

    public Tarea crear(String titulo) {
        long id = secuencia.incrementAndGet();
        Tarea tarea = new Tarea(id, titulo, false);
        tareas.put(id, tarea);
        return tarea;
    }

    public Optional<Tarea> buscar(long id) {
        return Optional.ofNullable(tareas.get(id));
    }
}

Y el recurso REST, que recibe el repositorio por inyección:

package com.example.tareas;

import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;

import java.util.List;

@Path("/tareas")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class TareaResource {

    @Inject
    TareaRepositorio repositorio;

    @GET
    public List<Tarea> listar() {
        return repositorio.listar();
    }

    @POST
    public Response crear(Tarea entrada, @Context UriInfo uriInfo) {
        if (entrada == null || entrada.getTitulo() == null || entrada.getTitulo().isBlank()) {
            return Response.status(Response.Status.BAD_REQUEST)
                    .entity("{"error":"El título es obligatorio"}")
                    .build();
        }
        Tarea creada = repositorio.crear(entrada.getTitulo().strip());
        var ubicacion = uriInfo.getAbsolutePathBuilder()
                .path(Long.toString(creada.getId()))
                .build();
        return Response.created(ubicacion).entity(creada).build();
    }

    @GET
    @Path("{id}")
    public Response obtener(@PathParam("id") long id) {
        return repositorio.buscar(id)
                .map(t -> Response.ok(t).build())
                .orElseGet(() -> Response.status(Response.Status.NOT_FOUND).build());
    }

    @PUT
    @Path("{id}/hecha")
    public Response completar(@PathParam("id") long id) {
        return repositorio.buscar(id)
                .map(t -> {
                    t.setHecha(true);
                    return Response.ok(t).build();
                })
                .orElseGet(() -> Response.status(Response.Status.NOT_FOUND).build());
    }
}

El POST devuelve 201 Created con la cabecera Location construida a partir de la URL de la petición, y las rutas que no existen devuelven 404.

Compilar y arrancar

mvn clean package
java -jar target/tareas.jar

El plugin descarga el runtime de Maven Central la primera vez, así que ese primer mvn package tarda más que los siguientes. Al terminar tendrás target/tareas.war y target/tareas.jar. Con el servidor arrancado, prueba la API:

curl -i -X POST http://localhost:9080/api/tareas 
     -H "Content-Type: application/json" 
     -d '{"titulo":"Probar el JAR de Liberty"}'

curl http://localhost:9080/api/tareas
curl -X PUT http://localhost:9080/api/tareas/1/hecha

La primera petición devuelve 201 con la tarea creada y su Location; la segunda lista las tareas en JSON; la tercera marca la tarea 1 como hecha. Para parar el servidor basta con Ctrl+C.

Configuración en el arranque

Un JAR que lleva su configuración dentro no es muy útil si el puerto no se puede cambiar. Liberty lo resuelve con las variables del server.xml: las que declaras con <variable> se pueden sobrescribir al lanzar el JAR con la sintaxis --nombre=valor. Según la documentación, las variables pasadas por línea de comandos tienen prioridad sobre cualquier otra fuente y no cambian después de que el servidor arranque.

java -jar target/tareas.jar --default.http.port=8081

Para controlar la extracción y la salida del servidor existen otras variables de entorno propias del JAR ejecutable:

  • WLP_JAR_EXTRACT_DIR: directorio exacto donde se extrae el JAR.
  • WLP_JAR_EXTRACT_ROOT: directorio padre; Liberty crea dentro una carpeta con el nombre del JAR y un sufijo numérico.
  • WLP_OUTPUT_DIR: dónde se escriben logs y ficheros de trabajo. Por defecto es el directorio de extracción, que se elimina al parar el servidor, así que si quieres conservar los logs tienes que apuntarlo a una ruta duradera.
  • WLP_JAR_ENABLE_2PC: a true habilita las transacciones de dos fases, desactivadas por defecto porque los logs de transacciones estarían en un directorio que se borra y la recuperación no sería posible.
export WLP_JAR_EXTRACT_DIR=/var/lib/tareas/runtime
export WLP_OUTPUT_DIR=/var/lib/tareas/output
java -jar tareas.jar

Dentro de un contenedor

El JAR ejecutable encaja bien en una imagen mínima basada en un JRE:

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/tareas.jar tareas.jar
ENV WLP_JAR_EXTRACT_DIR=/tmp/liberty
ENV WLP_OUTPUT_DIR=/tmp/liberty-output
EXPOSE 9080
ENTRYPOINT ["java", "-jar", "tareas.jar"]

Las dos variables de entorno apuntan a /tmp para que el arranque no dependa de que el usuario del contenedor tenga un directorio personal con permisos de escritura. Con un JAR de este tipo cada cambio de código rehace la capa entera, porque el runtime y la aplicación van en el mismo fichero. Para despliegues frecuentes, la documentación de Liberty ofrece otra vía: empaquetar solo el servidor con --include=usr, pensado para usarse sobre una instalación existente del runtime, como la imagen oficial de Open Liberty, de modo que el runtime quede en una capa estable y solo cambie la capa de la aplicación.

Open Liberty o Spring Boot

Las dos opciones producen un fichero que se lanza con java -jar, y esa es casi la única semejanza de fondo. La decisión suele depender de otras cosas:

Aspecto

Open Liberty + Jakarta EE

Spring Boot

Modelo de programación

Especificaciones Jakarta (REST, CDI, JPA...) con varias implementaciones posibles

APIs de Spring sobre su propio contenedor

Configuración del runtime

Features declaradas en server.xml

Starters de Maven y autoconfiguración

Cambio de servidor

El código estándar se traslada a otro servidor Jakarta EE sin reescribirlo

El código queda ligado a Spring

Arranque del JAR

Extrae a disco en cada arranque

Ejecuta con su lanzador sobre el JAR anidado

Ecosistema de librerías y ejemplos

Más reducido

Muy amplio, y es lo que conoce la mayoría del mercado

Observabilidad y métricas

Features MicroProfile en el mismo server.xml

Actuator y Micrometer

Liberty tiene sentido cuando ya hay código Jakarta EE o Java EE que se quiere conservar, cuando la organización exige apegarse al estándar o cuando se quiere activar solo las piezas que se usan y nada más. Spring Boot sigue siendo la opción más segura cuando el equipo ya lo domina, cuando el proyecto depende de librerías del universo Spring (Data, Security, Cloud) o cuando contratar perfiles es una prioridad. Tampoco son excluyentes: desde la versión 26.0.0.5 Open Liberty anuncia soporte para aplicaciones Spring Boot 4.0, de modo que es posible ejecutar un JAR de Spring Boot sobre Liberty cuando interesa su runtime.

Si buscas servidores aún más ligeros para microservicios, Quarkus y Micronaut es otra comparación útil, y sobre el lado Spring tienes la guía de Spring Boot 3.x en 2026 y el artículo sobre subida de ficheros con Spring Boot 4.

Errores habituales al empaquetar

  • El JAR arranca pero la aplicación no aparece. Casi siempre el location de webApplication no coincide con el nombre real del WAR. Fija finalName y compara.
  • package falla porque no existe el servidor. Ese objetivo no crea nada; asegúrate de que create y deploy van antes en la misma ejecución.
  • Error de packageType. Si include contiene runnable, el plugin exige packageType igual a jar.
  • Features que faltan en minify. El recorte depende de las features configuradas, así que si añades una más tarde tendrás que volver a empaquetar.
  • Logs que desaparecen. Sin WLP_OUTPUT_DIR, la salida vive en el directorio de extracción y se borra al parar el servidor.

Con este esqueleto ya tienes una base reproducible: el mismo mvn package que usarías para cualquier WAR genera un artefacto que se ejecuta en cualquier máquina con Java. A partir de aquí, el siguiente paso natural es sustituir el repositorio en memoria por Jakarta Persistence o Jakarta Data, que ya vienen incluidas en webProfile-11.0, y sumar MicroProfile para la salud y las métricas.

Imagen: Pexels / Brett Sayles

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP