Maven Packaging: JARs, WARs & Manifests

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Maven 3.10.0, maven-jar-plugin 3.5.1 (which uses Maven Archiver 3.6.6), maven-war-plugin 3.5.1 and maven-artifact-plugin 3.7.0. Every example was run on JDK 26 on 2026-10-16, in a scratch copy of the tidepool project (a parent POM and the tidepool-model, tidepool-engine and tidepool-cli modules, all compiled with maven.compiler.release 21 and every plugin version pinned). Where the behaviour differs, the same command was also run on Maven 3.9.16 and on Maven 4.0.0-rc-7 (a release candidate, not a final release). Paths in the console output were shortened to /work/packaging/..., and long console output was filtered with grep, which is said each time.

Packaging is the last step that is specific to the kind of project: the package phase of the build lifecycle turns target/classes into a file that can be installed, deployed or run. For <packaging>jar</packaging> (the default) the jar:jar goal does it, for war the war:war goal. Both plugins build the archive with the same component, Maven Archiver, and so share one <archive> configuration element. A JAR that bundles all its dependencies (a "fat" or "uber" JAR) is a separate topic, covered in Uber JARs with Shade & Assembly. This page covers the plain archives and what is inside their manifests. How a plugin is declared, pinned and configured is in Plugins & Their Configuration, and how the archive then gets into a repository is in Install, Deploy & Distribution Management.

What the jar plugin produces by default

A module with no packaging configuration at all gets a JAR containing the compiled classes and the main resources, a manifest, and a copy of the POM. Building tidepool-engine with mvn package and listing the result with unzip -l shows it:

$ mvn -q package -DskipTests -pl tidepool-engine -am
$ unzip -l tidepool-engine/target/tidepool-engine-1.0.0-SNAPSHOT.jar
Archive:  tidepool-engine/target/tidepool-engine-1.0.0-SNAPSHOT.jar
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  02-01-1980 00:00   META-INF/
       99  02-01-1980 00:00   META-INF/MANIFEST.MF
        0  02-01-1980 00:00   io/
        0  02-01-1980 00:00   io/tidepool/
        0  02-01-1980 00:00   io/tidepool/engine/
        0  02-01-1980 00:00   META-INF/maven/
        0  02-01-1980 00:00   META-INF/maven/io.tidepool/
        0  02-01-1980 00:00   META-INF/maven/io.tidepool/tidepool-engine/
     2530  02-01-1980 00:00   io/tidepool/engine/StationReader.class
     1775  02-01-1980 00:00   io/tidepool/engine/TideEngine.class
      732  02-01-1980 00:00   META-INF/maven/io.tidepool/tidepool-engine/pom.xml
       70  02-01-1980 00:00   META-INF/maven/io.tidepool/tidepool-engine/pom.properties
---------                     -------
     5206                     12 files

The entries are, in order of interest:

  • META-INF/MANIFEST.MF is the manifest, described in the next section. The default one has four lines and no Main-Class, so java -jar refuses the file: no main manifest attribute, in tidepool-engine/target/tidepool-engine-1.0.0-SNAPSHOT.jar (run here, exit status 1).

  • META-INF/maven/<groupId>/<artifactId>/pom.xml and pom.properties are the "Maven descriptor". pom.properties holds the three lines artifactId, groupId and version, and tools such as IDEs use the pair to recognise a JAR as a Maven artifact. They can be switched off, see below.

  • The date column shows 1980-02-01, which is not a bug but the reproducible-build timestamp, explained in Reproducible builds.

The dates in the listing are the same on Maven 3.10.0 and on 4.0.0-rc-7, but not on 3.9.16, where each entry carries the time of the build.

How the jar is attached and when it is rebuilt

jar:jar is bound to package for the jar packaging (see Build Lifecycle); its execution id is default-jar. The file is written to ${project.build.directory} as <artifactId>-<version>[-<classifier>].jar and attached to the project as its main artifact, which is what install and deploy publish. The parameters that matter most:

Parameter Meaning Default and user property

classesDirectory

The directory packaged into the JAR.

${project.build.outputDirectory} (target/classes)

includes, excludes

Fileset patterns, relative to classesDirectory, selecting what goes in.

Everything (but see addDefaultExcludes)

addDefaultExcludes

Whether editor and VCS litter (**/*~, **/#*#, **/.git/**, **/.DS_Store, …​) is left out.

true

classifier

Attaches the file as a supplemental artifact (tidepool-engine-1.0.0-reader.jar) instead of the main one.

none

skipIfEmpty

Do not create an archive when there is nothing to put in it.

false

forceCreation

Rebuild even when the inputs look unchanged. Needed when another plugin post-processes the JAR, such as the shade plugin.

false, user property maven.jar.forceCreation

attach

Whether the JAR is attached to the project.

true, user property maven.jar.attach

detectMultiReleaseJar

If META-INF/versions exists, add Multi-Release: true to the main section of the manifest.

true, user property maven.jar.detectMultiReleaseJar

outputTimestamp

Timestamp for every entry of the archive.

${project.build.outputTimestamp}

archive

The Maven Archiver configuration, next section.

none

Source: jar:jar

The list above was read from mvn jar:help -Ddetail -Dgoal=jar, which prints every parameter of the installed version and is the quickest way to check what a given plugin release accepts. The older useDefaultManifestFile parameter is only there to fail the build if used; the plugin’s manifest is configured with archive.

The archive element: Maven Archiver

The jar, war, ejb and ear plugins, and the assembly plugin, all accept an <archive> child in their configuration and hand it to Maven Archiver, a shared component with its own versioning. Its elements are:

Element Effect

addMavenDescriptor

Include META-INF/maven/<groupId>/<artifactId>/pom.xml and pom.properties (default true).

compress

Compress the archive (default true).

forced

Recreate the archive every time (default true); with false the archiver compares timestamps first.

index

Create an INDEX.LIST (default false). Deprecated by the JDK, see JDK-8305597.

pomPropertiesFile

Replace the generated pom.properties.

manifestFile

A manifest file of your own, merged with the generated one.

manifest

Settings for the main section of MANIFEST.MF, below.

manifestEntries

Free key/value pairs added to the main section.

manifestSections

Named sections of the manifest, each with its own manifestEntries.

The <manifest> element controls the generated main-section attributes:

Element Effect

mainClass

The Main-Class entry.

addClasspath

Create a Class-Path entry from the project’s dependencies (default false).

classpathPrefix

Text put in front of every Class-Path entry (default empty), for example lib/.

classpathLayoutType

simple (the file name, default), repository (the path of a Maven repository) or custom.

customClasspathLayout

The expression used when the layout is custom.

useUniqueVersions

For snapshots, use the timestamped file name instead of -SNAPSHOT (default true).

addExtensions

Create an Extension-List entry (default false).

packageName

The Package entry.

addDefaultEntries

The default Created-By, Build-Jdk-Spec and Java-Version lines (default true).

addDefaultImplementationEntries

Implementation-Title, Implementation-Version and Implementation-Vendor (default false).

addDefaultSpecificationEntries

Specification-Title, Specification-Version and Specification-Vendor (default false).

addBuildEnvironmentEntries

Build-Tool, Build-Jdk and Build-Os (default false).

The Implementation-* and Specification-* entries are off by default since maven-jar-plugin 2.1; the official manifest page says so and that they must be asked for explicitly. Two of the defaults are worth knowing: Java-Version reports the release the code was compiled for (here 21, from maven.compiler.release), while Build-Jdk-Spec reports the JDK that ran the build (26), and the Created-By value is chosen by the plugin, so a JAR says Maven JAR Plugin 3.5.1 and a WAR says Maven WAR Plugin 3.5.1.

The official "Guide to Working with Manifests" is only a pointer to the Maven Archiver page above, and the "Guide to Configuring Archive Plugins" contains the addMavenDescriptor recipe.

A runnable JAR with a lib directory

The most common use of the manifest is a JAR that java -jar can start. tidepool-cli has a Main class, and its dependencies (tidepool-engine, tidepool-model, Commons CSV with its two transitive libraries, and picocli) have to be found at run time. The manifest can name them through Class-Path, as paths relative to the location of the JAR, so the dependencies have to be copied next to it. The dependency:copy-dependencies goal of the dependency plugin does that.

tidepool-cli/pom.xml (build section; the dependencies and parent are as in the other pages)
<build>
  <plugins>
    <plugin>
      <artifactId>maven-jar-plugin</artifactId>
      <configuration>
        <archive>
          <manifest>
            <mainClass>io.tidepool.cli.Main</mainClass>
            <addClasspath>true</addClasspath>
            <classpathPrefix>lib/</classpathPrefix>
            <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
          </manifest>
          <manifestEntries>
            <Built-By>tidepool-build</Built-By>
            <Automatic-Module-Name>io.tidepool.cli</Automatic-Module-Name>
          </manifestEntries>
        </archive>
      </configuration>
    </plugin>
    <plugin>
      <artifactId>maven-dependency-plugin</artifactId>
      <version>3.11.0</version>
      <executions>
        <execution>
          <id>copy-runtime-libs</id>
          <phase>package</phase>
          <goals>
            <goal>copy-dependencies</goal>
          </goals>
          <configuration>
            <outputDirectory>${project.build.directory}/lib</outputDirectory>
            <includeScope>runtime</includeScope>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

The jar plugin’s version comes from pluginManagement in the parent POM. The dependency plugin runs in package after the jar plugin, because both are bound to the same phase and a plugin bound by the packaging runs before one added in the POM. Building the whole reactor and running the result:

$ mvn -B package -DskipTests | grep -E "Building jar|copy-dependencies|BUILD"
[INFO] Building jar: /work/packaging/tidepool-model/target/tidepool-model-1.0.0-SNAPSHOT.jar
[INFO] Building jar: /work/packaging/tidepool-model/target/tidepool-model-1.0.0-SNAPSHOT-tests.jar
[INFO] Building jar: /work/packaging/tidepool-engine/target/tidepool-engine-1.0.0-SNAPSHOT.jar
[INFO] Building jar: /work/packaging/tidepool-cli/target/tidepool-cli-1.0.0-SNAPSHOT.jar
[INFO] --- dependency:3.11.0:copy-dependencies (copy-runtime-libs) @ tidepool-cli ---
[INFO] BUILD SUCCESS
$ ls tidepool-cli/target/lib
commons-codec-1.19.0.jar
commons-csv-1.14.1.jar
commons-io-2.20.0.jar
picocli-4.7.7.jar
tidepool-engine-1.0.0-SNAPSHOT.jar
tidepool-model-1.0.0-SNAPSHOT.jar
$ unzip -p tidepool-cli/target/tidepool-cli-1.0.0-SNAPSHOT.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.5.1
Java-Version: 21
Build-Jdk-Spec: 26
Class-Path: lib/tidepool-engine-1.0.0-SNAPSHOT.jar lib/tidepool-model-1.
 0.0-SNAPSHOT.jar lib/commons-csv-1.14.1.jar lib/commons-io-2.20.0.jar l
 ib/commons-codec-1.19.0.jar lib/picocli-4.7.7.jar
Implementation-Title: tidepool-cli
Implementation-Version: 1.0.0-SNAPSHOT
Main-Class: io.tidepool.cli.Main
Automatic-Module-Name: io.tidepool.cli
Built-By: tidepool-build

$ java -jar tidepool-cli/target/tidepool-cli-1.0.0-SNAPSHOT.jar data/stations.csv SF
San Francisco: -0,869 m

A few details of that output:

  • The manifest specification limits a line to 72 bytes, so the long Class-Path is wrapped, and each continuation line begins with a single space. Concatenate the lines after removing that space to read the value.

  • The Class-Path order is the order of the plugin’s dependency list. Maven 3.10 changed how the resolver flattens the dependency graph to breadth-first, but the change showed up only in plugin class realms, not in the project’s own compile classpath (see Dependencies, Scopes & Mediation), and the order written to this manifest was identical when the same project was built with 3.9.16.

  • Built-By and Automatic-Module-Name are ordinary manifestEntries; any name can be added. Automatic-Module-Name gives a stable module name to a library JAR that has no module-info.class, and is the usual reason to touch manifestEntries in a library.

  • The number decoration (-0,869 with a comma) is the JVM’s default locale at run time, not part of the packaging.

The Class-Path entries are resolved relative to the JAR, not to the current directory. Copying the JAR alone to another directory and starting it there fails with Exception in thread "main" java.lang.NoClassDefFoundError: picocli/CommandLine, and starting it from another directory (java -jar with an absolute path to the original) still works. This is why a runnable JAR of this kind is distributed as a directory, usually zipped by the assembly plugin (see Uber JARs with Shade & Assembly); one file with everything inside is the shade or assembly approach on that page.

Other Class-Path layouts

classpathLayoutType changes what each Class-Path entry looks like. repository writes the path the artifact has in a Maven repository, which is useful when the JAR is started from a directory that is a copy of a repository (an application server’s lib/ is not that). Building a copy of the CLI with this configuration (and with addMavenDescriptor set to false) gave:

<archive>
  <addMavenDescriptor>false</addMavenDescriptor>
  <manifest>
    <addClasspath>true</addClasspath>
    <classpathLayoutType>repository</classpathLayoutType>
    <mainClass>io.tidepool.cli.Main</mainClass>
  </manifest>
</archive>
$ unzip -p target/tidepool-cli-repo-1.0.0-SNAPSHOT.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.5.1
Java-Version: 21
Build-Jdk-Spec: 26
Class-Path: io/tidepool/tidepool-engine/1.0.0-SNAPSHOT/tidepool-engine-1
 .0.0-SNAPSHOT.jar io/tidepool/tidepool-model/1.0.0-SNAPSHOT/tidepool-mo
 del-1.0.0-SNAPSHOT.jar org/apache/commons/commons-csv/1.14.1/commons-cs
 v-1.14.1.jar commons-io/commons-io/2.20.0/commons-io-2.20.0.jar commons
 -codec/commons-codec/1.19.0/commons-codec-1.19.0.jar info/picocli/picoc
 li/4.7.7/picocli-4.7.7.jar
Main-Class: io.tidepool.cli.Main

unzip -l on that JAR listed no entry containing maven, which is the effect of addMavenDescriptor set to false: the JAR has no META-INF/maven directory at all. The guide to archive configuration shows the same switch on the WAR plugin, and it applies to every plugin that takes <archive>.

With custom the layout is an expression evaluated against the artifact. The following was run, together with a second set of manifest features (an extra manifest file, a named section and the default specification and build-environment entries):

<archive>
  <manifestFile>src/main/manifest/extra.mf</manifestFile>
  <manifest>
    <addClasspath>true</addClasspath>
    <classpathLayoutType>custom</classpathLayoutType>
    <customClasspathLayout>lib/${artifact.artifactId}.${artifact.extension}</customClasspathLayout>
    <mainClass>io.tidepool.cli.Main</mainClass>
    <addDefaultSpecificationEntries>true</addDefaultSpecificationEntries>
    <addBuildEnvironmentEntries>true</addBuildEnvironmentEntries>
  </manifest>
  <manifestEntries>
    <Tidepool-Edition>enterprise</Tidepool-Edition>
  </manifestEntries>
  <manifestSections>
    <manifestSection>
      <name>io/tidepool/cli/</name>
      <manifestEntries>
        <Sealed>true</Sealed>
      </manifestEntries>
    </manifestSection>
  </manifestSections>
</archive>

where src/main/manifest/extra.mf contains the two lines Sealed: true and Tidepool-Edition: community:

$ unzip -p target/tidepool-cli-sections-1.0.0-SNAPSHOT.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.5.1
Java-Version: 21
Build-Jdk-Spec: 26
Build-Tool: Apache Maven 3.10.0 (c43a36b8d67be7e0805a411bc0898af1a51f547
 2)
Build-Jdk: 26 (Oracle Corporation)
Build-Os: Mac OS X (27.0.1; aarch64)
Class-Path: lib/tidepool-engine.jar lib/tidepool-model.jar lib/commons-c
 sv.jar lib/commons-io.jar lib/commons-codec.jar lib/picocli.jar
Specification-Title: tidepool-cli-sections
Specification-Version: 1.0
Main-Class: io.tidepool.cli.Main
Tidepool-Edition: community
Sealed: true

Name: io/tidepool/cli/
Sealed: true

The result shows how the pieces combine. Every main-section attribute comes from the union of the generated defaults, the manifest element, manifestEntries and the file. When the same key is set in two places, here Tidepool-Edition, the value in the manifest file is the one in the JAR; the enterprise value in the POM was dropped. Do not rely on the merge rule, give each key one home. A manifestSection writes a separate block after a blank line whose first line is Name:, used for per-package attributes such as Sealed. The Build-* lines record the machine and tool that built the file, so they make a build less reproducible, and the plugin leaves them off unless addBuildEnvironmentEntries is turned on (the Specification-Version is the major and minor version only).

Classifier JARs, includes and excludes

A second JAR from the same module is produced by adding an execution of jar:jar with a classifier. This one contains only the station reader:

<plugin>
  <artifactId>maven-jar-plugin</artifactId>
  <executions>
    <execution>
      <id>reader-only</id>
      <phase>package</phase>
      <goals>
        <goal>jar</goal>
      </goals>
      <configuration>
        <classifier>reader</classifier>
        <includes>
          <include>io/tidepool/engine/StationReader*.class</include>
        </includes>
      </configuration>
    </execution>
  </executions>
</plugin>
$ mvn -B package -DskipTests | grep -E "Building jar|BUILD"
[INFO] Building jar: /work/packaging/v3/target/tidepool-engine-slim-1.0.0-SNAPSHOT.jar
[INFO] Building jar: /work/packaging/v3/target/tidepool-engine-slim-1.0.0-SNAPSHOT-reader.jar
[INFO] BUILD SUCCESS
$ unzip -l target/tidepool-engine-slim-1.0.0-SNAPSHOT-reader.jar | grep class
     2530  02-01-1980 00:00   io/tidepool/engine/StationReader.class

The classic mistake is to forget the classifier. A second jar:jar execution without it would try to replace the main artifact, and the build stops:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-jar-plugin:3.5.1:jar (reader-only) on project tidepool-engine-slim: You have to use a classifier to attach supplemental artifacts to the project instead of replacing them. -> [Help 1]

A consumer asks for such a JAR with <classifier>reader</classifier> in its dependency. The conventional classifiers (sources, javadoc, tests) are produced by the source, javadoc and jar plugins in the same way (see Publishing to Maven Central, which needs the first two).

Test JARs

Test classes, helpers and fixtures are not part of the main artifact, but another module may need them. The jar plugin’s test-jar goal packages target/test-classes into a JAR with the classifier tests. The plugin documentation calls the approach below the preferred way, as opposed to a second execution with a classifier.

tidepool-model/pom.xml (build section)
<build>
  <plugins>
    <plugin>
      <artifactId>maven-jar-plugin</artifactId>
      <executions>
        <execution>
          <goals>
            <goal>test-jar</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>
tidepool-engine/pom.xml (an extra dependency)
<dependency>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-model</artifactId>
  <version>${project.version}</version>
  <type>test-jar</type>
  <scope>test</scope>
</dependency>
$ mvn -B verify -DskipTests -pl tidepool-model,tidepool-engine | grep -E "jar:|Building jar|BUILD"
[INFO] --- jar:3.5.1:jar (default-jar) @ tidepool-model ---
[INFO] Building jar: /work/packaging/tidepool-model/target/tidepool-model-1.0.0-SNAPSHOT.jar
[INFO] --- jar:3.5.1:test-jar (default) @ tidepool-model ---
[INFO] Building jar: /work/packaging/tidepool-model/target/tidepool-model-1.0.0-SNAPSHOT-tests.jar
[INFO] --- jar:3.5.1:jar (default-jar) @ tidepool-engine ---
[INFO] Building jar: /work/packaging/tidepool-engine/target/tidepool-engine-1.0.0-SNAPSHOT.jar
[INFO] BUILD SUCCESS
$ mvn -B dependency:tree -pl tidepool-engine | grep -E '^\[INFO\] (\+|\||\\)|tidepool-engine:jar'
[INFO] io.tidepool:tidepool-engine:jar:1.0.0-SNAPSHOT
[INFO] +- io.tidepool:tidepool-model:jar:1.0.0-SNAPSHOT:compile
[INFO] +- org.apache.commons:commons-csv:jar:1.14.1:compile
[INFO] |  +- commons-io:commons-io:jar:2.20.0:compile
[INFO] |  \- commons-codec:commons-codec:jar:1.19.0:compile
[INFO] \- io.tidepool:tidepool-model:test-jar:tests:1.0.0-SNAPSHOT:test

The test-jar goal is bound to package by default, takes its output from testClassesDirectory (${project.build.testOutputDirectory}) and has the classifier tests. Its dependency is declared with type test-jar (or equivalently <classifier>tests</classifier>) and almost always test scope, because production code that needs a test JAR is a sign that the helper belongs in a module of its own. In a reactor build the dependency resolved even when only the test phase was requested (mvn clean test -pl tidepool-model,tidepool-engine ran the model’s HeightTest successfully on both 3.10.0 and 3.9.16), because the reactor then points at target/test-classes. Installing or using the module outside the reactor needs the real JAR, i.e. package at least. The test JAR is also an artifact to install and deploy, so it lands in the repository next to the main one, which is the case in the file repository used for the reproducibility check below (...-tests.jar).

WAR files

A <packaging>war</packaging> module is a web application. The war plugin lays out an exploded directory (WEB-INF/classes, WEB-INF/lib, the contents of src/main/webapp) under target/<finalName> and zips it. The war packaging’s other lifecycle bindings are in Build Lifecycle. A small module tidepool-web was used to try it: one servlet (compiled against jakarta.servlet-api 6.1.0 in provided scope, so the container supplies it), a static page, and a dependency on tidepool-engine.

pom.xml of the web module (the directory is called war in the scratch copy)
<?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>
  <parent>
    <groupId>io.tidepool</groupId>
    <artifactId>tidepool-parent</artifactId>
    <version>1.0.0-SNAPSHOT</version>
  </parent>
  <artifactId>tidepool-web</artifactId>
  <packaging>war</packaging>

  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-engine</artifactId>
    </dependency>
    <dependency>
      <groupId>jakarta.servlet</groupId>
      <artifactId>jakarta.servlet-api</artifactId>
      <version>6.1.0</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <build>
    <finalName>tidepool</finalName>
    <plugins>
      <plugin>
        <artifactId>maven-war-plugin</artifactId>
        <version>3.5.1</version>
        <configuration>
          <failOnMissingWebXml>false</failOnMissingWebXml>
          <packagingExcludes>static/notes.txt,WEB-INF/lib/commons-codec-*.jar</packagingExcludes>
          <webResources>
            <resource>
              <directory>extra</directory>
              <targetPath>WEB-INF</targetPath>
              <includes>
                <include>BUILD-INFO.txt</include>
              </includes>
            </resource>
          </webResources>
          <archive>
            <addMavenDescriptor>false</addMavenDescriptor>
            <manifestEntries>
              <Tidepool-Edition>web</Tidepool-Edition>
            </manifestEntries>
          </archive>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>
$ mvn -B package | grep -E "war:|Building war|Assembling|Copying webapp|BUILD"
[INFO] --- war:3.5.1:war (default-war) @ tidepool-web ---
[INFO] Assembling webapp [tidepool-web] in [/work/packaging/war/target/tidepool]
[INFO] Copying webapp webResources [/work/packaging/war/extra] to [/work/packaging/war/target/tidepool]
[INFO] Copying webapp resources [/work/packaging/war/src/main/webapp]
[INFO] Building war: /work/packaging/war/target/tidepool.war
[INFO] BUILD SUCCESS
$ unzip -l war/target/tidepool.war
Archive:  war/target/tidepool.war
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  02-01-1980 00:00   META-INF/
      122  02-01-1980 00:00   META-INF/MANIFEST.MF
        0  02-01-1980 00:00   WEB-INF/
        0  02-01-1980 00:00   WEB-INF/classes/
        0  02-01-1980 00:00   WEB-INF/classes/io/
        0  02-01-1980 00:00   WEB-INF/classes/io/tidepool/
        0  02-01-1980 00:00   WEB-INF/classes/io/tidepool/web/
        0  02-01-1980 00:00   WEB-INF/lib/
        0  02-01-1980 00:00   static/
       24  02-01-1980 00:00   WEB-INF/BUILD-INFO.txt
     1568  02-01-1980 00:00   WEB-INF/classes/io/tidepool/web/StationsServlet.class
    60964  02-01-1980 00:00   WEB-INF/lib/commons-csv-1.14.1.jar
   563971  02-01-1980 00:00   WEB-INF/lib/commons-io-2.20.0.jar
     4396  02-01-1980 00:00   WEB-INF/lib/tidepool-engine-1.0.0-SNAPSHOT.jar
     5524  02-01-1980 00:00   WEB-INF/lib/tidepool-model-1.0.0-SNAPSHOT.jar
       59  02-01-1980 00:00   index.html
       34  02-01-1980 00:00   static/site.css
---------                     -------
     17 files
$ unzip -p war/target/tidepool.war META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: Maven WAR Plugin 3.5.1
Java-Version: 21
Build-Jdk-Spec: 26
Tidepool-Edition: web

What the configuration did:

  • finalName is a build-level setting, not a war plugin one, and renames tidepool-web-1.0.0-SNAPSHOT.war to tidepool.war. A war with a fixed name is what a servlet container deploys under a stable context path. The artifact installed and deployed to a repository is still named by the coordinates, so the file name is only about the file in target.

  • packagingExcludes removed static/notes.txt (a file in src/main/webapp that must not ship) and the Commons Codec JAR from WEB-INF/lib (it is a transitive dependency that the container is assumed to provide). The war plugin documents this as the way to include or exclude files after the webapp has been assembled; warSourceExcludes does the equivalent on the source directory before assembly. The user property maven.war.packagingExcludes sets the same list on the command line.

  • webResources adds a directory that is not under src/main/webapp, with an optional targetPath, includes, excludes and filtering. The example copies one file into WEB-INF.

  • The provided servlet API is not in WEB-INF/lib, as it must not be: the container supplies it.

  • The servlet class is in WEB-INF/classes, because a module with war packaging compiles like any Java module; only the last step differs.

web.xml and the Servlet API

Since the Servlet 3.0 specification a web.xml is optional, because servlets can be declared with annotations (the example uses @WebServlet). The war plugin’s failOnMissingWebXml parameter is documented as defaulting to false since 3.1.0 if the project depends on the Servlet 3.0 API or newer, and the example above worked with the line removed, because jakarta.servlet-api is on its classpath. A module with no servlet API and no src/main/webapp/WEB-INF/web.xml, which is the case of a module that only holds static files, fails:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-war-plugin:3.5.1:war (default-war) on project tidepool-static: Error assembling WAR: webxml attribute is required (or pre-existing WEB-INF/web.xml if executing in update mode) -> [Help 1]

The cure is <failOnMissingWebXml>false</failOnMissingWebXml>, as in the tidepool-web configuration above. Setting it explicitly is also the safer habit, because the default depends on dependency detection.

Other war parameters

The goals of the war plugin are war (the one bound to package), exploded and inplace (assemble the web application as a directory for a quick local run) and manifest. The parameters, taken from mvn war:help -Ddetail, include:

Parameter Meaning

warSourceDirectory, warSourceIncludes, warSourceExcludes

The web source directory (default ${basedir}/src/main/webapp) and filters on it.

webXml, containerConfigXML

Use a web.xml from elsewhere, or add a container configuration file such as context.xml to META-INF.

webResources

Additional directories merged into the archive, optionally filtered.

overlays, dependentWarIncludes, dependentWarExcludes

Merge the contents of another WAR (an overlay, a dependency of type war) into this one.

packagingIncludes, packagingExcludes

Filter the assembled files before they are zipped.

outputFileNameMapping

Rename the JARs in WEB-INF/lib.

archiveClasses, attachClasses, classesClassifier

Put WEB-INF/classes into a JAR (WEB-INF/lib/<name>.jar), and attach that JAR with the classifier classes so that other modules can depend on it.

filters, filteringDeploymentDescriptors, delimiters, nonFilteredFileExtensions

Resource filtering of web resources (see Resources & Filtering).

recompressZippedFiles

Recompress the JARs and zips copied into the archive (default true).

skip

Skip the goal (user property maven.war.skip).

classifier, primaryArtifact

Attach the WAR with a classifier instead of as the main artifact.

archive

Maven Archiver configuration, as above.

Source: war:war

The war plugin documents a manifest Class-Path recipe of its own: with addClasspath set a WAR gets the entries of its dependencies, which duplicates WEB-INF/lib, so the recipe controls which dependencies appear in the manifest and which in the directory.

Spring Boot and Quarkus applications are packaged by their own plugins (see Uber JARs with Shade & Assembly for the general approach and Quarkus build tooling for one framework’s); the classic WAR is still the deployable for servlet containers.

Reproducible builds

A build is reproducible when the same sources, build environment and build instructions give bit-by-bit identical files. For packaging the main enemy is time: ZIP entries carry a modification time. The plugins that write archives therefore accept a single outputTimestamp, which the whole build sets through the project.build.outputTimestamp property, as an ISO 8601 date-time (2026-10-16T00:00:00Z) or as epoch seconds. With it set the jar, war, source, javadoc, assembly and shade plugins write that value into every entry.

The default since Maven 3.10

Before Maven 3.10 a project opted in by defining the property itself. Since Maven 3.10.0 (and 4.0.0-beta-5) the super POM defines it, so that every project is in the reproducible mode without touching its POM, with the fixed value 1980-02-01T00:00:00Z. The same lookup in the three Maven versions:

The command help:evaluate -Dexpression=project.build.outputTimestamp -q -DforceStdout -pl tidepool-engine printed, in each Maven:

Maven Output

3.9.16

null object or invalid expression

3.10.0

1980-02-01T00:00:00Z

4.0.0-rc-7

1980-02-01T00:00:00Z

The effect shows in the SHA-256 of the engine JAR, built twice with three seconds between the builds and no property in any POM (the first 16 hexadecimal digits are shown):

== 3.10.0
acc73b37f6487efd
acc73b37f6487efd
== 3.9.16
15f1cd5b90e6af4c
5fe2087cb7ea21b4

Maven 3.10 produced the same file twice, and 3.9.16 produced two different ones. On 3.9.16 the property can be given on the command line, -Dproject.build.outputTimestamp=2026-10-16T00:00:00Z, and then both builds were identical (009dc46da9c5e874, entries dated 10-16-2026 00:00). In your POM, set it in <properties>, so that the value is visible and the build behaves the same on 3.9:

<properties>
  <project.build.outputTimestamp>2026-10-16T00:00:00Z</project.build.outputTimestamp>
</properties>

The official guide gives two ways to keep this value current: the release plugin (3.0.0-M1 or later) updates it in the same commit that sets the release version, and the versions plugin’s versions:set updates it from release 2.9.0. A fixed value in a parent POM, as here, is perfect for a snapshot; a release should carry a timestamp from its own release. Some projects use the last Git commit time (${git.commit.time}) for it. See Releasing.

To opt out, the guide says any non-numeric single-character value disables the mode. Tried here with -Dproject.build.outputTimestamp=x on 3.10.0: two builds three seconds apart gave different SHA-256 values (7d52307fcb1cbf10 and 4996fb6b4aa5f420) and entries dated with the minute of the build.

Checking it with artifact:compare

Equal hashes between two local builds prove little. The artifact plugin’s compare goal compares the artifacts of the current build with a reference build, which is either the one in the local repository or the one in a repository named with -Dreference.repo. It exits with an error if the files differ, and writes the result to target/<artifactId>-<version>.buildcompare. To try it without a remote server, a standalone copy of tidepool-model with the release version 1.0.0 (snapshots get timestamped names, which would not compare) was deployed to a file repository with mvn clean deploy -DaltDeploymentRepository=ref::file:///work/packaging/refrepo; the copy of the artifacts that the deploy put into the local repository was then deleted, so that the reference is read from the file repository and the comparison cannot use it. The rebuild:

$ mvn -B clean verify artifact:compare -Dreference.repo=ref::file:///work/packaging/refrepo | grep -E "artifact:|Checking|Comparing|reference build|rebuild comparison|saved to|BUILD|WARN|ERROR"
[INFO] --- artifact:3.7.0:compare (default-cli) @ tidepool-model ---
[WARNING] No source information available in buildinfo for rebuilders...
[INFO] Checking against reference build from local repository or ref::file:///work/packaging/refrepo...
[INFO] Comparing against 2 reference files from ref (file:///work/packaging/refrepo, default, releases+snapshots)
[INFO]     reference build java.version: 26 (from MANIFEST.MF Build-Jdk-Spec)
[INFO]     reference build os.name: Unix (from pom.properties newline)
[INFO] [Reproducible Builds] rebuild comparison result: 2 files match
[INFO]                                                  saved to target/tidepool-model-1.0.0.buildcompare
[INFO] BUILD SUCCESS

Rebuilding with a different timestamp (-Dproject.build.outputTimestamp=2026-10-17T00:00:00Z) is what a non-reproducible build looks like:

[ERROR] sha512 mismatch tidepool-model-1.0.0.jar: investigate with diffoscope target/reference/io.tidepool/tidepool-model-1.0.0.jar target/tidepool-model-1.0.0.jar
[ERROR] [Reproducible Builds] rebuild comparison result: 1 files match, 1 differ
[ERROR]                                                  saved to target/tidepool-model-1.0.0.buildcompare
[ERROR] [Reproducible Builds] to analyze the differences, see diffoscope instructions in target/tidepool-model-1.0.0.buildcompare
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-artifact-plugin:3.7.0:compare (default-cli) on project tidepool-model: Rebuilt artifacts are different from reference -> [Help 1]
$ cat target/tidepool-model-1.0.0.buildcompare
version=1.0.0
ok=1
ko=1
ignored=0
okFiles="tidepool-model-1.0.0.pom"
koFiles="tidepool-model-1.0.0.jar"
ignoredFiles=""
reference_java_version="26 (from MANIFEST.MF Build-Jdk-Spec)"
reference_os_name="Unix (from pom.properties newline)"
# diffoscope target/reference/io.tidepool/tidepool-model-1.0.0.jar target/tidepool-model-1.0.0.jar

The POM matched, the JAR did not, and the file ends with the diffoscope command that would show where the two JARs differ (diffoscope is not installed here, so that step was not run). The guide’s own quick test is mvn clean install followed by mvn clean verify artifact:compare, and for a staged release mvn verify artifact:compare -Dreference.repo=<staging URL>. The guide warns that a local comparison does not prove independence from the environment (user name, directory, JDK version and the line endings that differ between Windows and Unix); a real check rebuilds on a different machine. The goal reads the JDK version and the operating system family of the reference build from its manifest and pom.properties (the two reference build lines above), and the rebuild writes its own target/*.buildinfo file.

The companion goal artifact:check-buildplan lists plugins in the build that are known not to be reproducible. For the standalone project:

$ mvn -B clean package artifact:check-buildplan | grep -E "check-buildplan|No known|BUILD"
[INFO] --- artifact:3.7.0:check-buildplan (default-cli) @ tidepool-model ---
[INFO] No known issue in 6 plugins
[INFO] BUILD SUCCESS

What else breaks reproducibility

Two elements of the archive configuration of this page make a JAR depend on the environment: addBuildEnvironmentEntries (operating system, JDK and Maven version) and a Built-By/Build-Time entry computed at build time. Dependency version ranges (see Versions, Snapshots & Ranges) and unpinned plugin versions change the content with no change in your project: an unpinned jar or war plugin gets the version in the running Maven’s lifecycle bindings, which changes with every Maven upgrade, so pin every plugin (see Enforcer and Security & Reproducible Builds for the checks and the supply-chain side). The JDK is also a factor: even with the same release, different major JDK versions generate different bytecode, and the guide advises recording it.

Changes between Maven versions

Version What changes for packaging

3.9.16

project.build.outputTimestamp is not defined unless the project sets it, so a JAR built twice differs. The archive plugins behave as described on this page.

3.10.0

project.build.outputTimestamp defaults to 1980-02-01T00:00:00Z through the super POM, so the plugins' reproducible mode is on for every project. The super POM no longer manages the antrun, assembly, dependency and release plugins; the jar and war plugin versions still come from the lifecycle bindings (maven-war-plugin 3.5.1 in the effective POM of a bare war project on 3.10.0), but they change with Maven, so pinning them is advised (this page’s examples pin them in the parent).

4.0.0-rc-7 (release candidate)

Same default timestamp (checked with the lookup above and the entry dates of the engine JAR). Maven 4 also introduces new dependency types for JARs, classpath-jar, modular-jar, processor, classpath-processor and modular-processor, to say whether a dependency belongs on the class path or the module path (see Maven 4 POM Model & Consumer POM).

The jar plugin has pre-releases that are not used here: 4.0.0-beta-1 of maven-jar-plugin and 4.0.0-beta-5 of Maven Archiver exist on Central, but the stable versions named at the top of the page are the ones that were run.

Where older material is outdated

The Maven 2 book that informed these pages (Maven 2.2) has little to say on packaging beyond the lifecycle, but a few things that circulate with it are out of date:

  • Plugin versions were commonly left out of the POM. Maven 3 warns about it, and an unpinned maven-jar-plugin or maven-war-plugin takes the version in the running Maven’s lifecycle bindings, which changes from one Maven release to the next; declare them with a version (directly or in pluginManagement).

  • A manifest Class-Path plus a lib/ directory is documented for the old maven-assembly-plugin, but for an executable JAR the assembly plugin’s descriptor and the shade plugin have taken over (see Uber JARs with Shade & Assembly).

  • The jar.useDefaultManifestFile parameter and the manifestFile-only recipes of old posts: the former now only breaks the build, and the manifest is configured with archive.

  • A web.xml is not required for a servlet 3.0 WAR, and the javax.servlet coordinates were replaced by jakarta.servlet in Servlet 5 and later.

  • Reproducibility was not a concept in Maven 2. The timestamp in the archives was the build time.

Equivalents in Ant and Gradle

Maven Ant Gradle

jar:jar

The jar task, with a nested manifest; see Packaging Archives.

The jar task of the java plugin; jar { manifest { attributes(...) } }. See Java Projects.

<archive><manifest><mainClass>

<attribute name="Main-Class" .../> inside <manifest>.

An entry Main-Class in manifest.attributes, or the application plugin.

addClasspath, classpathPrefix

The manifestclasspath task builds the Class-Path from a path.

Write Class-Path from the runtime classpath in the manifest block yourself.

Test JAR with the test-jar goal

A second jar task over the test classes.

The java-test-fixtures plugin, which publishes a separate test-fixtures variant.

Classifier JAR

Another jar task.

A second Jar task with archiveClassifier.

WAR from war:war

The war task.

The war plugin.

project.build.outputTimestamp

Set the modificationtime of the archive tasks, and see SOURCE_DATE_EPOCH on the Ant page.

Archive tasks with isPreserveFileTimestamps = false and isReproducibleFileOrder = true.

The cross-tool picture is in Comparing Ant, Maven and Gradle.