ZetCode

Maven WAR packaging

last modified November 13, 2026

In this article we build a web application with Maven. We package a Jakarta servlet application into a WAR file, look at its layout, see why the servlet API has the provided scope, and run the application with the Jetty Maven plugin. The examples use Maven 3.10, WAR plugin 3.5.1, Jetty 12.1, Servlet 6.1, and Java 25.

A WAR (web application archive) is a ZIP file with a layout defined by the Jakarta Servlet specification. It is deployed to a servlet container, such as Tomcat or Jetty, or to an application server. The container provides the HTTP server and the servlet API; the WAR contains the application code, its libraries, and static files. Projects with war packaging use the Maven WAR plugin instead of the JAR plugin in the package phase.

The project

Our application greets users. A form sends a name to a servlet, which answers with an HTML fragment.

greeting/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>com.example</groupId>
    <artifactId>greeting</artifactId>
    <version>1.0.0</version>
    <packaging>war</packaging>

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
    </properties>

    <dependencies>
        <dependency>
            <groupId>jakarta.servlet</groupId>
            <artifactId>jakarta.servlet-api</artifactId>
            <version>6.1.0</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-text</artifactId>
            <version>1.15.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.5.1</version>
            </plugin>
            <plugin>
                <groupId>org.eclipse.jetty.ee11</groupId>
                <artifactId>jetty-ee11-maven-plugin</artifactId>
                <version>12.1.14</version>
                <configuration>
                    <webApp>
                        <contextPath>/greeting</contextPath>
                    </webApp>
                    <httpConnector>
                        <port>8085</port>
                    </httpConnector>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

The packaging element is set to war.

<dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>6.1.0</version>
    <scope>provided</scope>
</dependency>

The servlet API is needed to compile the servlet. It has the provided scope, because the container supplies it at runtime. Commons Text, which escapes the name, is a normal dependency.

<groupId>org.eclipse.jetty.ee11</groupId>
<artifactId>jetty-ee11-maven-plugin</artifactId>
<version>12.1.14</version>

The Jetty plugin runs the application in an embedded Jetty server. Jetty 12 has a separate plugin for each Jakarta EE version; the ee11 plugin supports Servlet 6.1. The application is served under the /greeting context path on port 8085.

greeting/src/main/java/com/example/greeting/GreetingServlet.java
package com.example.greeting;

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.text.StringEscapeUtils;

import java.io.IOException;

@WebServlet("/hello")
public class GreetingServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {

        var name = req.getParameter("name");
        if (name == null || name.isBlank()) {
            name = "guest";
        }

        resp.setContentType("text/html;charset=UTF-8");
        resp.getWriter().printf("<p>Hello, %s!</p>%n",
                StringEscapeUtils.escapeHtml4(name));
    }
}

The @WebServlet annotation maps the servlet to the /hello path; the container finds annotated classes when it deploys the application. The servlet reads the name parameter and escapes it with StringEscapeUtils.escapeHtml4, so the user cannot inject HTML into the page.

greeting/src/main/webapp/index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Greeting</title>
    <link rel="stylesheet" href="css/style.css">
</head>
<body>
    <form action="hello">
        <input name="name" placeholder="Your name">
        <button>Greet</button>
    </form>
</body>
</html>

Static files go into src/main/webapp, which becomes the root of the application. The form submits the name to the servlet with a GET request. The directory also contains css/style.css.

Building the WAR

We package the application.

The WAR plugin first assembles the application in the target/greeting-1.0.0 directory, called an exploded WAR, and then zips it. The content of src/main/webapp is at the root, the compiled classes are in WEB-INF/classes, and the runtime dependencies are in WEB-INF/lib, including Commons Lang, which Commons Text depends on. The servlet API is not there. The container never serves files from WEB-INF to clients.

$ mvn package
--- war:3.5.1:war (default-war) @ greeting ---
Packaging webapp
Assembling webapp [greeting] in
  [/home/user/project/greeting/target/greeting-1.0.0]
Copying webapp resources [/home/user/project/greeting/src/main/webapp]
Building war: /home/user/project/greeting/target/greeting-1.0.0.war
BUILD SUCCESS
$ unzip -l target/greeting-1.0.0.war
Archive:  target/greeting-1.0.0.war
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  1980-02-01 00:00   META-INF/
       99  1980-02-01 00:00   META-INF/MANIFEST.MF
        0  1980-02-01 00:00   WEB-INF/
        0  1980-02-01 00:00   WEB-INF/classes/
        0  1980-02-01 00:00   WEB-INF/classes/com/
        0  1980-02-01 00:00   WEB-INF/classes/com/example/
        0  1980-02-01 00:00   WEB-INF/classes/com/example/greeting/
        0  1980-02-01 00:00   WEB-INF/lib/
        0  1980-02-01 00:00   css/
     1482  1980-02-01 00:00   WEB-INF/classes/com/example/greeting/GreetingServlet.class
   713862  1980-02-01 00:00   WEB-INF/lib/commons-lang3-3.20.0.jar
   264873  1980-02-01 00:00   WEB-INF/lib/commons-text-1.15.0.jar
       47  1980-02-01 00:00   css/style.css
      296  1980-02-01 00:00   index.html
     1758  1980-02-01 00:00   META-INF/maven/com.example/greeting/pom.xml
       54  1980-02-01 00:00   META-INF/maven/com.example/greeting/pom.properties
---------                     -------
   982471                     16 files
$ ls target target/greeting-1.0.0
target:
classes
generated-sources
greeting-1.0.0
greeting-1.0.0.war
maven-archiver
maven-status

target/greeting-1.0.0:
css
index.html
META-INF
WEB-INF

The provided scope

To see the effect of the provided scope, we change the scope of the servlet API to compile.

The API JAR now gets into WEB-INF/lib. In the best case, the container ignores it; in the worst case, two copies of the servlet classes of different versions clash, and the application fails with ClassCastException or LinkageError errors. The same applies to every API that the container implements, such as Jakarta Persistence or Jakarta REST in application servers.

$ mvn -q clean package && unzip -l target/greeting-1.0.0.war | grep WEB-INF/lib/
        0  1980-02-01 00:00   WEB-INF/lib/
   713862  1980-02-01 00:00   WEB-INF/lib/commons-lang3-3.20.0.jar
   264873  1980-02-01 00:00   WEB-INF/lib/commons-text-1.15.0.jar
   398424  1980-02-01 00:00   WEB-INF/lib/jakarta.servlet-api-6.1.0.jar

Running with Jetty

The jetty:run goal compiles the project and starts Jetty with the application. It runs until it is stopped with Ctrl+C; we start it in one terminal and use curl in another.

The run goal does not build a WAR. It serves the static files directly from src/main/webapp and the classes from target/classes, so changes to HTML and CSS files are visible without a restart. With the scan parameter set to a number of seconds, the plugin also redeploys the application when classes change. The servlet answers under the context path, and the name is escaped.

$ mvn jetty:run
--- jetty:12.1.14:run (default-cli) @ greeting ---
Classes = /home/user/project/greeting/target/classes
Context path = /greeting
web.xml file = null
Webapp directory = /home/user/project/greeting/src/main/webapp
Started oejs.ServerConnector@473847fb{HTTP/1.1, (http/1.1)}{0.0.0.0:8085}
Started oejs.Server@1ab53860{STARTING}[12.1.14,sto=0] @457ms
$ curl -s localhost:8085/greeting/ | head -4
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
$ curl -s 'localhost:8085/greeting/hello?name=Lucy'
<p>Hello, Lucy!</p>
$ curl -s 'localhost:8085/greeting/hello?name=<b>Lucy</b>'
<p>Hello, &lt;b&gt;Lucy&lt;/b&gt;!</p>

The deployment descriptor

Older applications configure servlets in WEB-INF/web.xml, the deployment descriptor. Since Servlet 3.0 it is optional, and since WAR plugin 3.0 a missing web.xml no longer fails the build. It is still the place for settings that have no annotation, such as error pages.

src/main/webapp/WEB-INF/web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_1.xsd"
         version="6.1">

    <display-name>Greeting</display-name>

    <error-page>
        <error-code>404</error-code>
        <location>/not-found.html</location>
    </error-page>
</web-app>

The descriptor declares Servlet 6.1 and shows the not-found.html page for 404 errors. The page is a static file in src/main/webapp. Servlets annotated with @WebServlet keep working, because the descriptor does not set metadata-complete.

This time we start the server with the jetty:run-war goal, which runs the build up to the package phase and deploys the packaged WAR, just like a standalone container does.

A request for a missing page returns the 404 status with our error page. To deploy the same file to a standalone server, it is enough to copy target/greeting-1.0.0.war to its deployment directory, for example webapps in Tomcat 11; the context path is then derived from the file name.

$ mvn jetty:run-war
Building war: /home/user/project/greeting/target/greeting-1.0.0.war
--- jetty:12.1.14:run-war (default-cli) @ greeting ---
War = /home/user/project/greeting/target/greeting-1.0.0.war
Started oejs.ServerConnector@7b13ff02{HTTP/1.1, (http/1.1)}{0.0.0.0:8085}
Started oejs.Server@6abe62bb{STARTING}[12.1.14,sto=0] @608ms
$ curl -si localhost:8085/greeting/missing
HTTP/1.1 404 Not Found
<body><p>The page does not exist.</p></body>
$ curl -s 'localhost:8085/greeting/hello?name=Lucy'
<p>Hello, Lucy!</p>

Useful options

ParameterDescription
warSourceDirectoryweb application sources, src/main/webapp by default
webResourcesadditional resources, optionally filtered
packagingExcludesfiles left out of the WAR
failOnMissingWebXmlrequire web.xml, false by default
warNamename of the WAR file
attachClassesalso create a JAR with the classes
overlaysmerge other WARs into this one

The finalName element in the build section sets the base name of the WAR, for example greeting instead of greeting-1.0.0, which gives a version-independent context path in a standalone container.

Source

Apache Maven WAR Plugin

In this article we have built a web application into a WAR file with Maven and run it with the Jetty Maven plugin.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Java tutorials.