Getting Started

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 Jakarta Pages 4.0, Jakarta Expression Language 6.0, Jakarta Standard Tag Library 3.0 (API 3.0.2, implementation 3.0.1) and Jakarta Servlet 6.1, running on Java 21 and Apache Tomcat 11.0.26 (the latest 11.0.x release on 2026-10-08, when every fact and example below was checked). Eclipse Jetty 12.1.14 and Eclipse GlassFish 8.0.5 are covered as alternatives. All examples were built with Maven and exercised with curl against a scratch Tomcat 11.0.26 on Java 21; the Jetty, Cargo, GlassFish and Docker variants were run too, and each snippet is followed by the official page it derives from. The page takes the reader from an empty folder to the first working page of the Bookshelf — a catalogue of books, authors, reviews and loans that this section builds twice, as bookshelf-jsp in Part I and as bookshelf-struts in Part II — and explains each moving part on the way: the JDK, the container, the WAR, the first servlet, the first JSP with JSTL, the ways to deploy it, and a Docker image.

What You Need

A JSP application is a web application archive (WAR) that a servlet container unpacks and runs. The container supplies the Servlet, Pages and Expression Language APIs and the JSP engine; the application brings its own classes, its views and, with Tomcat, the JSTL jars. Four pieces are therefore needed.

Piece Version used here Role

JDK

Java 21 (an LTS release; Tomcat 11 and Pages 4.0 need 17 or later)

Compiles the servlets, runs the container, and lets Jasper compile each JSP into a class (Tomcat bundles the Eclipse JDT compiler for that).

Servlet container

Apache Tomcat 11.0.26 (Servlet 6.1, Pages 4.0, EL 6.0, WebSocket 2.2)

Receives HTTP requests, maps them to servlets and JSPs, manages sessions and translates JSPs into servlets.

Build tool

Apache Maven 3.9 or later, with maven-war-plugin 3.4.0

Compiles the classes, assembles WEB-INF/classes, WEB-INF/lib and the web content into one .war file.

JSTL implementation

org.glassfish.web:jakarta.servlet.jsp.jstl 3.0.1 with jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api 3.0.2

The tag library (c:, fmt:, fn:, sql:, x:). Tomcat does not ship it, so it travels inside the WAR.

Tomcat’s own documentation states the support matrix: Tomcat 11.0.x implements Servlet 6.1, JSP 4.0, EL 6.0, WebSocket 2.2 and Authentication 3.1 (the versions required by the Jakarta EE 11 platform) and runs on Java 17 and later, while Tomcat 10.1.x implements Servlet 6.0 and JSP 3.1 on Java 11 and later, and Tomcat 9.0.x still implements the javax.* Servlet 4.0 and JSP 2.3 on Java 8 and later.

Choosing a Container

Any Jakarta EE 11 servlet container runs the examples of this section. The three that matter in practice differ in what they are and how a JSP is supported.

Apache Tomcat 11.0.26 Eclipse Jetty 12.1.14 Eclipse GlassFish 8.0.5

What it is

A servlet container: Servlet, Pages, EL, WebSocket and Authentication, nothing else

A servlet container and HTTP server, embeddable, with one "environment" per Jakarta EE release (ee8 to ee11)

A full application server; the Platform and Web Profile distributions are Jakarta EE 11 products

JSP engine

Jasper (Apache), built in

Apache Jasper, packaged with the ee11-jsp module and in jetty-ee11-apache-jsp

Eclipse WaSP for Pages, Expressly for EL

JSTL

Not bundled: add the API and the implementation to WEB-INF/lib

The WAR’s own JSTL jars were used (verified with the Maven plugin; the Jetty documentation also lists a JSTL module)

Provided by the server; a WAR that carries its own copy also ran (verified)

Deploy by

Copy to webapps/, Manager, context descriptors, Maven plugins

Maven plugin jetty:run, a monitored webapps directory once the ee11-deploy module is enabled, or the embedded API

asadmin deploy, the administration console, or copying the archive into the domain’s autodeploy directory

Use it for

The reference for this section; the most common production container for JSP

Fast edit-run loops from Maven and tests that start a server in the JVM

Applications that also need the wider Jakarta EE platform (CDI, Persistence, Messaging)

Tomcat is the one the examples assume. Jetty and GlassFish are shown further down with the same WAR; a portability difference between them (records in EL) is listed in the pitfalls table. Issue #116 (IDE links) is referenced here in prose only: every IDE with Jakarta EE tooling can start Tomcat 11, Jetty 12 or GlassFish 8 from a run configuration and deploy the exploded project into it, so nothing in this section depends on a particular IDE; use one only if it can run a server whose Servlet level is 6.1.

Installing the JDK

Install a JDK (not only a JRE) of version 21: the servlet compiler, Maven and Tomcat’s startup scripts all use it. Distributions such as Eclipse Temurin (https://adoptium.net/) and the OpenJDK builds listed on https://openjdk.org/projects/jdk/21/ are interchangeable for this purpose. Tomcat’s RUNNING.txt states that Tomcat 11.0 requires a Java SE Runtime Environment version 17 or later and that a full JDK may be used instead of a JRE. Point JAVA_HOME at the JDK and check both the compiler and the runtime.

Checking the JDK and Maven
export JAVA_HOME=/path/to/jdk-21
export PATH="$JAVA_HOME/bin:$PATH"
java -version     # openjdk version "21.x.y"
javac -version    # javac 21.x.y
mvn -version      # Apache Maven 3.9.x ... Java version: 21.x.y

Maven prints the Java version it runs with, which is the quickest way to find a JAVA_HOME that points at the wrong JDK. Installing Maven itself is covered by the Maven project (https://maven.apache.org/install.html); the Java language and its tools are covered elsewhere on this site (Java Getting Started and Build & Tooling).

Installing Tomcat 11

Tomcat is a directory, not an installer: download the "Core" archive from the Tomcat 11 download page, verify it, unpack it and run its scripts. The download page publishes OpenPGP signatures and SHA-512 checksums for every file and asks users to check the integrity of what they download. The checksum file has the format hash *filename, which shasum reads directly.

Downloading, verifying and unpacking Tomcat 11.0.26
curl -O https://dlcdn.apache.org/tomcat/tomcat-11/v11.0.26/bin/apache-tomcat-11.0.26.tar.gz
curl -O https://downloads.apache.org/tomcat/tomcat-11/v11.0.26/bin/apache-tomcat-11.0.26.tar.gz.sha512
shasum -a 512 -c apache-tomcat-11.0.26.tar.gz.sha512    # apache-tomcat-11.0.26.tar.gz: OK
tar xzf apache-tomcat-11.0.26.tar.gz
export CATALINA_HOME="$PWD/apache-tomcat-11.0.26"
"$CATALINA_HOME/bin/version.sh"
Server version: Apache Tomcat/11.0.26
Server built:   Sep 9 2026 21:36:42 UTC
Server number:  11.0.26.0
...
JVM Version:    21.0.x+y-LTS

(downloads.apache.org serves the checksum files of current releases and archive.apache.org keeps every older release; the download page links both. On Windows use the "Windows zip" distribution.)

What Is in the Directory

The unpacked archive is the Tomcat home. The documentation distinguishes CATALINA_HOME, the installation with the jars and scripts, from CATALINA_BASE, the configuration, logs and applications of one running instance. By default both point to the same directory, which is the right choice until a second instance is needed.

Directory Content (Tomcat 11.0.26, as unpacked)

bin/

startup.sh, shutdown.sh, catalina.sh, version.sh, configtest.sh, digest.sh (password digests), migrate.sh (the Jakarta EE migration tool), the .bat twins for Windows, and setenv.sh if you create it for JAVA_HOME and JVM options.

conf/

server.xml (connectors, host, appBase), web.xml (defaults for every application, including the JSP servlet and its parameters), context.xml, tomcat-users.xml (users and roles for the Manager), logging.properties.

lib/

The container’s classes, shared by every web application: servlet-api.jar, jsp-api.jar, el-api.jar, websocket-api.jar, jaspic-api.jar, annotations-api.jar, jasper.jar, jasper-el.jar, ecj-4.40.jar (the Eclipse compiler Jasper uses) and jakartaee-migration-1.0.12-shaded.jar. There is no JSTL jar.

logs/

catalina.<date>.log (startup, deployment), localhost.<date>.log (application errors and ServletContext.log lines), localhost_access_log.<date>.txt.

webapps/

The default appBase: WAR files and exploded applications dropped here are deployed. The archive ships ROOT, docs, examples, manager and host-manager.

work/ and temp/

Generated JSP sources and classes (work/Catalina/localhost/<context>/org/apache/jsp/...) and temporary files.

The fact that lib/ has the Servlet, Pages and EL APIs and no JSTL jar explains the Maven scopes used later: the three APIs are provided (the container already has them) and the JSTL API and implementation are packaged into the WAR.

The default applications are a security consideration, not only a convenience. Tomcat’s security guide says that the applications that ship enabled have had vulnerabilities in the past, that unnecessary ones should be removed, that the examples application should always be removed from a security-sensitive installation, and that the Manager, which allows remote deployment, is frequently targeted. A development machine can keep them; a production image should not (the Docker section below starts from an empty webapps).

Starting and Stopping

startup.sh runs catalina.sh start in the background, shutdown.sh stops the instance through the shutdown port declared on the Server element of server.xml, and catalina.sh run keeps the server in the foreground with the log on the console, which is the mode to use in a terminal and in a container. The HTTP connector listens on port 8080 and the shutdown port is 8005 by default; the verification runs for this page used other ports because several servers ran side by side, which changes nothing but the port number in the URLs.

Starting Tomcat, checking it and stopping it
"$CATALINA_HOME/bin/startup.sh"
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/    # 200: the ROOT application
tail -f "$CATALINA_HOME/logs/catalina.$(date +%Y-%m-%d).log"
"$CATALINA_HOME/bin/shutdown.sh"

If the page of the ROOT application opens, the container, the JDK and the port are fine. The examples application that ships in webapps/ has runnable JSP and servlet samples (its JSP index answered 200 at /examples/jsp/ in the check); it is useful for a first look and, as said above, the first thing to remove from anything that faces a network. The remaining problems come from the application: its WAR, its descriptor or its classes.

Jetty 12.1 and GlassFish 8 as Alternatives

Both alternatives run the same WAR that the rest of this page builds, so the instructions are short. They are written after the WAR exists; the full runs are in the Maven plugins and GlassFish sections below. The differences to know in advance:

  • Jetty 12 is organised in environments, one per Jakarta EE release, and the Maven plugin is repackaged for each one with an eeX in the groupId and the artifactId. For Jakarta EE 11 the plugin is org.eclipse.jetty.ee11:jetty-ee11-maven-plugin (12.1.14 on 2026-10-08), and the namespace of the application must be jakarta.*.

  • GlassFish 8 is the Jakarta EE 11 line. Its download page offers the Platform and the Web Profile distributions (Maven Central has org.glassfish.main.distributions:glassfish and org.glassfish.main.distributions:web 8.0.5; the download page itself still advertised 8.0.4 on 2026-10-08). The Web Profile distribution is enough for JSP and servlets.

  • Neither product removes the need for jakarta.* imports: a javax.servlet application does not run unchanged on any of the three (see the pitfalls table).

A Maven WAR From Scratch

The smallest useful project has a pom.xml, a folder of Java classes, and a folder of web content. Everything under src/main/webapp becomes the root of the WAR, and WEB-INF inside it is the private part: nothing in it is served directly to a browser. Create the layout first, then fill the files in.

The project at the end of this page
bookshelf-jsp/
├── pom.xml
└── src/main
    ├── java/org/example/bookshelf
    │   ├── book/            Book.java, BookService.java
    │   └── web/             HelloServlet.java, CatalogueServlet.java, VisitServlet.java,
    │                        Greeting.java, StartupListener.java, PoweredByFilter.java
    └── webapp
        ├── index.html       welcome page
        ├── first-steps.jsp  a page that may be requested directly
        ├── scriptlet.jsp    a legacy-style page, for reading old code
        ├── catalogue.bks    static file with a custom MIME type
        └── WEB-INF
            ├── web.xml      deployment descriptor (Servlet 6.1)
            ├── jsp/         catalogue.jsp, visits.jsp, about.jsp, not-found.jsp, banner.jspf
            └── tags/        rating.tag

The folder names follow the usual Maven and Servlet conventions: src/main/java holds the classes, src/main/webapp the web content, WEB-INF/jsp the views that only a servlet may forward to, and WEB-INF/tags the tag files that Jasper discovers automatically when a page declares tagdir="/WEB-INF/tags". The later pages of this section add author, review and loan packages beside book, a tlds folder and a messages resource bundle.

The pom.xml

pom.xml of bookshelf-jsp
<?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>org.example</groupId>
    <artifactId>bookshelf-jsp</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>war</packaging>

    <properties>
        <maven.compiler.release>21</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- Supplied by the container: compile against them, never package them -->
        <dependency>
            <groupId>jakarta.servlet</groupId>
            <artifactId>jakarta.servlet-api</artifactId>
            <version>6.1.0</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>jakarta.servlet.jsp</groupId>
            <artifactId>jakarta.servlet.jsp-api</artifactId>
            <version>4.0.0</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>jakarta.el</groupId>
            <artifactId>jakarta.el-api</artifactId>
            <version>6.0.1</version>
            <scope>provided</scope>
        </dependency>
        <!-- Not supplied by Tomcat: the JSTL API and its implementation travel inside the WAR -->
        <dependency>
            <groupId>jakarta.servlet.jsp.jstl</groupId>
            <artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
            <version>3.0.2</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.web</groupId>
            <artifactId>jakarta.servlet.jsp.jstl</artifactId>
            <version>3.0.1</version>
        </dependency>
    </dependencies>

    <build>
        <finalName>bookshelf</finalName>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.4.0</version>
            </plugin>
        </plugins>
    </build>
</project>

Each line of the file earns its place.

Element Why it is there

<packaging>war</packaging>

Selects the WAR lifecycle: package ends in maven-war-plugin:war, which builds target/<finalName>.war.

maven.compiler.release 21

Compiles for Java 21 and checks against the Java 21 API. Class files built for a newer release than the JDK that runs Tomcat fail at load time (see the pitfalls table).

jakarta.servlet:jakarta.servlet-api 6.1.0, scope provided

The Servlet API to compile against. provided means "available at compile time, supplied by the container at run time": it is not copied into the WAR.

jakarta.servlet.jsp:jakarta.servlet.jsp-api 4.0.0, provided

The Pages API, needed to compile tag handlers and anything that uses PageContext or JspWriter. Tomcat has it in lib/jsp-api.jar.

jakarta.el:jakarta.el-api 6.0.1, provided

The EL API, needed to compile EL functions, resolvers and custom tags. Tomcat has it in lib/el-api.jar.

jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api 3.0.2 and org.glassfish.web:jakarta.servlet.jsp.jstl 3.0.1, default scope

The JSTL API and the implementation that contains the TLDs and tag handlers. They must be in WEB-INF/lib because Tomcat does not provide them. The implementation jar’s META-INF/*.tld files declare the URIs jakarta.tags.core, jakarta.tags.fmt and the rest, which is how <%@ taglib uri="jakarta.tags.core" %> is resolved.

finalName bookshelf

The WAR is target/bookshelf.war. With Tomcat’s default deployment the file name is the context path, so the application answers at /bookshelf/.

maven-war-plugin 3.4.0

Pinned so that builds are reproducible. Since 3.1.0 the plugin no longer requires a web.xml when the project depends on the Servlet 3.0 API or newer, so failOnMissingWebXml is not needed.

The jars with provided scope are visible to the compiler and to the tests but are left out of the WAR, so the only jars in WEB-INF/lib are the two JSTL ones.

About the Maven Archetype

mvn archetype:generate -DarchetypeArtifactId=maven-archetype-webapp produces a WAR project in one command, and it is tempting. In the check made for this page (archetype version 1.5, the latest) it produced a pom.xml with maven.compiler.source and target set to 8, and a web.xml for Servlet 4.0 in the old xmlns.jcp.org/xml/ns/javaee namespace, plus an index.jsp file. Generate it if you like, then replace the descriptor with the one below and the compiler setting with release 21; or start from the files on this page. There is no archetype for Jakarta EE 11 web applications to rely on, so this page builds the project by hand.

The Deployment Descriptor

A Servlet 6.1 container can run a WAR with no web.xml at all, because servlets, filters and listeners can be declared with annotations. A descriptor is still the place for the settings that belong to the deployment rather than to the code, and Tomcat reads it for session, error-page and MIME settings. This is the one the Bookshelf uses; it exercises each element that a getting-started application typically needs.

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>Bookshelf (JSP)</display-name>

    <context-param>
        <param-name>bookshelf.pageSize</param-name>
        <param-value>20</param-value>
    </context-param>

    <filter>
        <filter-name>poweredBy</filter-name>
        <filter-class>org.example.bookshelf.web.PoweredByFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>poweredBy</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <listener>
        <listener-class>org.example.bookshelf.web.StartupListener</listener-class>
    </listener>

    <!-- A JSP registered as a servlet, so it gets a clean URL without ".jsp" -->
    <servlet>
        <servlet-name>about</servlet-name>
        <jsp-file>/WEB-INF/jsp/about.jsp</jsp-file>
    </servlet>
    <servlet-mapping>
        <servlet-name>about</servlet-name>
        <url-pattern>/about</url-pattern>
    </servlet-mapping>

    <session-config>
        <session-timeout>30</session-timeout>
        <cookie-config>
            <http-only>true</http-only>
        </cookie-config>
    </session-config>

    <mime-mapping>
        <extension>bks</extension>
        <mime-type>application/vnd.bookshelf+json</mime-type>
    </mime-mapping>

    <welcome-file-list>
        <welcome-file>index.html</welcome-file>
    </welcome-file-list>

    <error-page>
        <error-code>404</error-code>
        <location>/WEB-INF/jsp/not-found.jsp</location>
    </error-page>
</web-app>
Element What it does Verified behaviour

<web-app version="6.1"> and the https://jakarta.ee/xml/ns/jakartaee namespace

Declares the descriptor schema. The namespace changed from http://xmlns.jcp.org/xml/ns/javaee (Servlet 3.x and 4.0) when Jakarta EE 9 renamed the platform.

The WAR deployed and started on Tomcat 11.0.26.

<context-param>

An application-wide initialisation parameter, read with ServletContext.getInitParameter(name) or ${initParam['name']} in EL.

about.jsp printed page size 20.

<filter> and <filter-mapping>

Declares a filter class and the URL patterns it applies to; the filter sees every request before the servlet. Here PoweredByFilter adds one response header and passes the request on.

GET /bookshelf/hello carried X-Bookshelf: getting-started.

<listener>

Registers a ServletContextListener (or another listener type); here it logs once at startup.

localhost.<date>.log got "Bookshelf started on Apache Tomcat/11.0.26, page size 20".

<servlet> with <jsp-file> and <servlet-mapping>

Registers a JSP as a named servlet and maps it to a URL pattern, so the page can be reached at /about instead of /WEB-INF/jsp/about.jsp.

GET /bookshelf/about returned 200 with the page.

<session-config>

The session timeout in minutes and the session cookie attributes (http-only here).

The JSESSIONID cookie carried HttpOnly.

<mime-mapping>

Maps a file extension to a content type for static files.

GET /bookshelf/catalogue.bks answered Content-Type: application/vnd.bookshelf+json.

<welcome-file-list>

The files tried, in order, for a request that ends in a directory name.

GET /bookshelf/ returned index.html.

<error-page>

Maps an HTTP status code or an exception type to a page inside the WAR.

GET /bookshelf/nothing answered 404 with the Bookshelf’s own page.

The PoweredByFilter class is a dozen lines: it implements jakarta.servlet.Filter, calls ((HttpServletResponse) response).setHeader("X-Bookshelf", "getting-started") and then chain.doFilter(request, response). Filter ordering, wrappers and the other listener types, <jsp-config> property groups, <taglib> mappings and the rest of the descriptor belong to the Servlet Fundamentals page and to Part I; the point here is that an application with one web.xml and a handful of classes is already a complete deployable unit.

The First Servlet

A servlet is a Java class that extends HttpServlet and turns a request into a response. With the @WebServlet annotation no registration is needed; the container finds the class while scanning the WAR. The first servlet answers with plain text and shows what the container reports about itself, which makes it a good smoke test for any new environment.

HelloServlet.java
package org.example.bookshelf.web;

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

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

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain");
        resp.setCharacterEncoding("UTF-8");
        try (PrintWriter out = resp.getWriter()) {
            out.println("Hello from Bookshelf on " + getServletContext().getServerInfo());
            out.println("Servlet API " + getServletContext().getMajorVersion() + "."
                    + getServletContext().getMinorVersion());
            out.println("Context path " + req.getContextPath());
        }
    }
}

Build it, copy the WAR to webapps/ and request the URL. The three parts of the path are the container’s context path (/bookshelf, from the WAR’s file name), the servlet’s URL pattern (/hello) and nothing else, because the servlet has no extra path.

Building and calling the first servlet
mvn -q package
cp target/bookshelf.war "$CATALINA_HOME/webapps/"
curl http://localhost:8080/bookshelf/hello
Hello from Bookshelf on Apache Tomcat/11.0.26
Servlet API 6.1
Context path /bookshelf

getServerInfo() names the container, getMajorVersion() and getMinorVersion() report the Servlet API level the container implements, and getContextPath() returns the path under which the application is mounted. The same WAR printed jetty/12.1.14 and Eclipse GlassFish 8.0.5 for the first line on those servers, with the same two other lines.

The First JSP and JSTL Page

A servlet is good at deciding and bad at producing markup. The Model 2 arrangement used throughout Part I therefore keeps both jobs apart: a servlet handles the request, puts the model in a request attribute and forwards to a JSP that renders it. The view lives under WEB-INF/jsp, so a browser cannot request it directly.

The model: Book.java and BookService.java
package org.example.bookshelf.book;

public record Book(long id, String title, String author, int year) {
}
package org.example.bookshelf.book;

import java.util.List;
import java.util.Locale;

/** In-memory catalogue used by the first Bookshelf pages (a real application would call a repository). */
public class BookService {

    private static final List<Book> BOOKS = List.of(
            new Book(1, "Dune", "Frank Herbert", 1965),
            new Book(2, "Neuromancer", "William Gibson", 1984),
            new Book(3, "The Left Hand of Darkness", "Ursula K. Le Guin", 1969),
            new Book(4, "R&D <Notes>", "A. Tester", 2020));

    public List<Book> findAll(String filter) {
        if (filter == null || filter.isBlank()) {
            return BOOKS;
        }
        String needle = filter.toLowerCase(Locale.ROOT);
        return BOOKS.stream()
                .filter(b -> b.title().toLowerCase(Locale.ROOT).contains(needle)
                        || b.author().toLowerCase(Locale.ROOT).contains(needle))
                .toList();
    }
}

The fourth title contains markup characters on purpose: it proves, in the output below, that the view escapes what it prints.

The controller: CatalogueServlet.java
package org.example.bookshelf.web;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.example.bookshelf.book.BookService;

@WebServlet(urlPatterns = {"/books"})
public class CatalogueServlet extends HttpServlet {

    private final BookService books = new BookService();

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String filter = req.getParameter("filter");
        req.setAttribute("books", books.findAll(filter));
        req.setAttribute("filter", filter == null ? "" : filter);
        req.getRequestDispatcher("/WEB-INF/jsp/catalogue.jsp").forward(req, resp);
    }
}
The view: WEB-INF/jsp/catalogue.jsp
<%@ page session="false" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="bs" tagdir="/WEB-INF/tags" %>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Bookshelf catalogue</title>
</head>
<body>
<jsp:include page="/WEB-INF/jsp/banner.jspf"/>
<form action="${pageContext.request.contextPath}/books" method="get">
  <input type="search" name="filter" value="<c:out value='${filter}'/>">
  <button type="submit">Search</button>
</form>
<c:choose>
  <c:when test="${empty books}">
    <p>No books match "<c:out value="${filter}"/>".</p>
  </c:when>
  <c:otherwise>
    <ul>
      <c:forEach var="b" items="${books}" varStatus="s">
        <li class="${s.index % 2 == 0 ? 'even' : 'odd'}"><c:out value="${b.title}"/> by <c:out value="${b.author}"/> (${b.year}) <bs:rating value="${b.id}"/></li>
      </c:forEach>
    </ul>
    <p>${books.size()} book(s)</p>
  </c:otherwise>
</c:choose>
</body>
</html>
The two small helpers: banner.jspf and rating.tag
<h1>Bookshelf</h1>
<%@ tag body-content="empty" pageEncoding="UTF-8" %>
<%@ attribute name="value" required="true" type="java.lang.Integer" %>
<span class="rating" title="${value} out of 5">${value} / 5</span>

Build and deploy, then ask for the catalogue and for a filtered one.

Requesting the catalogue
curl http://localhost:8080/bookshelf/books
curl "http://localhost:8080/bookshelf/books?filter=gib"
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Bookshelf catalogue</title>
</head>
<body>
<h1>Bookshelf</h1>
<form action="/bookshelf/books" method="get">
  <input type="search" name="filter" value="">
  <button type="submit">Search</button>
</form>
    <ul>
        <li class="even">Dune by Frank Herbert (1965)
<span class="rating" title="1 out of 5">1 / 5</span>
</li>
        <li class="odd">Neuromancer by William Gibson (1984)
<span class="rating" title="2 out of 5">2 / 5</span>
</li>
        <li class="even">The Left Hand of Darkness by Ursula K. Le Guin (1969)
<span class="rating" title="3 out of 5">3 / 5</span>
</li>
        <li class="odd">R&amp;D &lt;Notes&gt; by A. Tester (2020)
<span class="rating" title="4 out of 5">4 / 5</span>
</li>
    </ul>
    <p>4 book(s)</p>
</body>
</html>

(The blank lines that the directives and tags leave in the real output were removed above.) The second request returns only the Neuromancer item and <p>1 book(s)</p>; a filter with no match prints No books match "zzz"., and a filter such as "><script> is echoed back as &#034;&gt;&lt;script&gt;, because c:out escapes <, >, &, ' and ".

Read the page from top to bottom.

Construct What happens

<%@ page session="false" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>

A directive, processed at translation time. contentType sets the response type and charset, pageEncoding says how the JSP file itself is encoded. session="false" stops the page from creating an HTTP session; the default is true, so every JSP creates one unless told otherwise (see the pitfalls table).

<%@ taglib prefix="c" uri="jakarta.tags.core" %>

Makes the JSTL core library available under the prefix c. The URI is the one defined by JSTL 3.0 and found in the implementation jar. The bs library is not a jar: tagdir="/WEB-INF/tags" points at tag files.

${pageContext.request.contextPath}

An EL expression in template text. pageContext is one of the implicit objects; contextPath calls getContextPath(). It keeps the link right whatever name the WAR is deployed under.

<c:out value="${b.title}"/>

Prints a value with XML escaping on. The model values that come from users or databases are printed with c:out (or fn:escapeXml); the numeric ${b.year} needs no escaping.

<c:choose>, <c:when test="${empty books}">, <c:otherwise>

Conditional rendering; empty is true for a null, empty collection or empty string.

<c:forEach var="b" items="${books}" varStatus="s">

Loops over the list; s.index is the zero-based position, used here for the even and odd classes. The record components are read with ${b.title}: EL 6.0 resolves them through RecordELResolver.

<jsp:include page="/WEB-INF/jsp/banner.jspf"/>

A standard action that includes another resource at request time, here a fragment with the page header.

<bs:rating value="${b.id}"/>

A tag file call: the file WEB-INF/tags/rating.tag is compiled into a tag handler and used like a tag. It is the first look at the reusable components of Part I (Tag Files & Layouts).

${books.size()}

EL calls a method on the list. Method calls in EL have been possible since EL 2.2.

The model in the request travels from the servlet to the JSP through request.setAttribute("books", ...). The EL expression ${books} searches the page, request, session and application scopes in that order and finds it in the request scope.

First Steps With JSP Features

The catalogue page used a handful of features without introducing them. Before moving on, the next page shows the smaller steps that an introduction to JSP normally takes: template text, EL, request parameters, a JavaBean, jsp:include, a session, and what a scriptlet looks like (for reading old code only).

Template Text, Parameters, Beans and Includes

Everything in a JSP that is not a directive, an action or a scripting element is template text and is copied to the response unchanged. EL expressions in the template text are evaluated and printed. A JavaBean is a class with a public no-argument constructor and getters and setters; the standard actions jsp:useBean, jsp:setProperty and jsp:getProperty create one in a scope and read and write its properties.

src/main/webapp/first-steps.jsp
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<jsp:useBean id="hello" class="org.example.bookshelf.web.Greeting" scope="request"/>
<jsp:setProperty name="hello" property="name" param="name"/>
<!DOCTYPE html>
<html lang="en">
<body>
<jsp:include page="/WEB-INF/jsp/banner.jspf"/>
<p><jsp:getProperty name="hello" property="message"/></p>
<p>Hello, <c:out value="${empty param.name ? 'reader' : param.name}"/></p>
<p>Request method: ${pageContext.request.method}, context path: ${pageContext.request.contextPath}</p>
<p>2 + 3 = ${2 + 3}</p>
<ul>
<c:forEach var="i" begin="1" end="3">
  <li>Row ${i}<c:if test="${i % 2 == 0}"> (even)</c:if></li>
</c:forEach>
</ul>
</body>
</html>
The bean: Greeting.java
package org.example.bookshelf.web;

/** A JavaBean: public no-argument constructor plus getters and setters. */
public class Greeting {

    private String name = "reader";

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getMessage() {
        return "Welcome to the Bookshelf, " + name + "!";
    }
}
Requesting the page with and without a parameter
curl "http://localhost:8080/bookshelf/first-steps.jsp?name=Ada"
<!DOCTYPE html>
<html lang="en">
<body>
<h1>Bookshelf</h1>

<p>Welcome to the Bookshelf, Ada!</p>
<p>Hello, Ada</p>
<p>Request method: GET, context path: /bookshelf</p>
<p>2 + 3 = 5</p>
<ul>

  <li>Row 1</li>

  <li>Row 2 (even)</li>

  <li>Row 3</li>

</ul>
</body>
</html>

Without ?name=Ada the bean keeps its default and the page says "Welcome to the Bookshelf, reader!" and "Hello, reader". jsp:setProperty with param="name" copies the request parameter name into the bean property of the same name; if the parameter is absent, the property is not touched. The EL implicit object param reads the same parameter directly, and the ?: operator picks a default. Unlike jsp:getProperty, the c:out call escapes the value, which is why it is the safer choice for anything that came from a user.

Sessions

HTTP is stateless: the server does not remember the previous request. A session gives each browser a server-side map identified by a cookie, JSESSIONID. The first servlet that needs it calls request.getSession(), which creates the session and makes the container send the cookie; JSPs read what the servlet stored through the sessionScope EL object.

VisitServlet.java and WEB-INF/jsp/visits.jsp
package org.example.bookshelf.web;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;

@WebServlet("/visits")
public class VisitServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        HttpSession session = req.getSession();
        int visits = session.getAttribute("visits") instanceof Integer n ? n + 1 : 1;
        session.setAttribute("visits", visits);
        req.getRequestDispatcher("/WEB-INF/jsp/visits.jsp").forward(req, resp);
    }
}
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html lang="en">
<body>
<p>You have visited this page ${sessionScope.visits} time(s) in this session.</p>
</body>
</html>

curl keeps the cookie between calls when it is given a cookie jar, which shows the session at work.

Three requests that share one session
curl -s -c jar.txt                http://localhost:8080/bookshelf/visits | grep visited
curl -s -b jar.txt -c jar.txt     http://localhost:8080/bookshelf/visits | grep visited
curl -s -b jar.txt -c jar.txt     http://localhost:8080/bookshelf/visits | grep visited
<p>You have visited this page 1 time(s) in this session.</p>
<p>You have visited this page 2 time(s) in this session.</p>
<p>You have visited this page 3 time(s) in this session.</p>

The cookie jar shows the cookie scoped to the context path with the HttpOnly flag that the descriptor requested: #HttpOnly_localhost FALSE /bookshelf FALSE 0 JSESSIONID <id>. Sessions in depth (timeouts, URL rewriting, the SameSite attribute) are the subject of Servlet Fundamentals and of Forms, Validation & Sessions in Part I.

A Scriptlet, for Reading Old Code

Older JSP code puts Java inside the page with scriptlets (<% ... %>), expressions (<%= ... %>) and declarations. The syntax still works on Tomcat 11, which is why it is shown, but only as a "before" snippet: it is hard to test, mixes logic into markup, and is exactly what EL, JSTL and tag files replace.

A scriptlet page (legacy style, do not write new code this way)
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" import="java.util.Date" %>
<html>
<body>
<%-- legacy style, shown only as a "before" snippet --%>
<% String name = request.getParameter("name"); if (name == null) { name = "reader"; } %>
<p>Hello, <%= name %> at <%= new Date().getClass().getSimpleName() %></p>
<ul>
<% for (int i = 1; i <= 3; i++) { %>
  <li>Row <%= i %><% if (i % 2 == 0) { %> (even)<% } %></li>
<% } %>
</ul>
</body>
</html>

The same loop and condition written with EL and JSTL are the three lines in first-steps.jsp above (<c:forEach var="i" begin="1" end="3"> with <c:if test="${i % 2 == 0}">). Both pages printed the same list: Row 1, Row 2 (even), Row 3. Requesting the scriptlet page with ?name=Bob printed Hello, Bob at Date. Notice what the scriptlet page does not do: it prints name without escaping, so ?name=<script>... would be executed by the browser, while the EL and c:out version above escapes it. The import attribute of the page directive is the only place where a Java import belongs in a JSP; the session attribute, the buffer attribute and the other directives are the subject of Directives in Part I.

What Happens to a Request

Putting the pieces together shows what the container does for the request GET /bookshelf/books?filter=dune.

sequenceDiagram autonumber participant B as Browser participant C as Tomcat connector participant M as Mapper participant S as CatalogueServlet participant D as RequestDispatcher participant J as catalogue.jsp as a servlet B->>C: GET /bookshelf/books?filter=dune C->>M: context path /bookshelf, servlet path /books M->>S: service() then doGet(request, response) S->>S: BookService.findAll, request.setAttribute books and filter S->>D: forward to /WEB-INF/jsp/catalogue.jsp D->>J: _jspService(request, response) J-->>B: 200 text/html, template text plus the c:forEach output
Step What the container does

1-2

The connector parses the HTTP request. The mapper picks the web application whose context path is the longest prefix of the URL (/bookshelf), then the servlet whose URL pattern matches the rest (/books).

3

The container calls service(), which dispatches by HTTP method to doGet. The same servlet instance serves every request, on a thread per request; the servlet must therefore hold no per-request state in instance fields.

4-5

The servlet builds the model and forwards. The forward does not send a response to the browser: it hands the same request and response objects to the target.

6

The target is a JSP. The first time it is requested, Jasper translates it into a Java source file, compiles it with the Eclipse JDT compiler and loads the class; the file work/Catalina/localhost/bookshelf/org/apache/jsp/WEB_002dINF/jsp/catalogue_jsp.java can be read in the Tomcat directory. The class extends org.apache.jasper.runtime.HttpJspBase, has _jspInit, _jspService and _jspDestroy methods, and writes the template text with out.write(...). Later requests reuse the compiled class.

7

_jspService runs the page with the same request, evaluates the EL and JSTL tags against the attributes the servlet set, and writes the HTML into a buffered JspWriter; when the buffer is flushed the response is sent.

That is why the first request to a JSP is slower than the following ones, and why a syntax error in a JSP appears as an error page at request time and not at deployment time (Jasper can also precompile all JSPs at build time; Jasper Configuration & Precompilation explains how). By default, Tomcat’s JSP servlet runs in development mode: it checks the modification time of the JSP file at most every few seconds (the modificationTestInterval parameter, default 4) and recompiles a changed page on the next request. In the verification for this page, editing first-steps.jsp in the deployed directory changed the response within five seconds without a restart.

Building the WAR and Looking Inside

Building and listing the WAR
mvn clean package
unzip -l target/bookshelf.war
META-INF/MANIFEST.MF
first-steps.jsp
index.html
catalogue.bks
WEB-INF/web.xml
WEB-INF/classes/org/example/bookshelf/book/Book.class
WEB-INF/classes/org/example/bookshelf/book/BookService.class
WEB-INF/classes/org/example/bookshelf/web/CatalogueServlet.class
WEB-INF/classes/org/example/bookshelf/web/Greeting.class
WEB-INF/classes/org/example/bookshelf/web/HelloServlet.class
WEB-INF/classes/org/example/bookshelf/web/StartupListener.class
WEB-INF/classes/org/example/bookshelf/web/VisitServlet.class
WEB-INF/jsp/about.jsp
WEB-INF/jsp/banner.jspf
WEB-INF/jsp/catalogue.jsp
WEB-INF/jsp/not-found.jsp
WEB-INF/jsp/visits.jsp
WEB-INF/tags/rating.tag
WEB-INF/lib/jakarta.servlet.jsp.jstl-api-3.0.2.jar
WEB-INF/lib/jakarta.servlet.jsp.jstl-3.0.1.jar
META-INF/maven/org.example/bookshelf-jsp/pom.xml
META-INF/maven/org.example/bookshelf-jsp/pom.properties

(The META-INF/maven entries and the directory entries are omitted from some listings; the order depends on the plugin.) This is the contents a servlet container expects: the public files at the root, and under WEB-INF the descriptor, the classes in classes/, the jars in lib/, the private views and the tag files. The three provided APIs are absent from WEB-INF/lib, as intended. A mvn clean matters when files are renamed or moved: without it the old target/classes and target/<finalName> directories can leak a stale file into the WAR (a WEB-INF/views/catalogue.jsp left over from an earlier layout appeared in a build made for this page until clean was used).

Deploying to Tomcat

flowchart LR src["pom.xml, src/main/java, src/main/webapp"] --> mvn["mvn package"] mvn --> war["target/bookshelf.war"] war --> how{"How is it deployed?"} how -->|"copy the file"| web["CATALINA_BASE/webapps/bookshelf.war"] how -->|"HTTP PUT"| mgr["Manager text interface /manager/text/deploy"] how -->|"Maven plugin"| mp["jetty:run or cargo:run"] web --> hc["Host: deployOnStartup and autoDeploy"] mgr --> hc hc --> ctx["Context /bookshelf: unpack, scan annotations, start servlets"] mp --> ctx

Copying the WAR to webapps/

The simplest deployment is a file copy. The Host element of server.xml has an appBase (default webapps), and with the default deployOnStartup="true" and autoDeploy="true" Tomcat deploys what it finds there at startup and keeps scanning while running: a new WAR, an exploded directory or a context descriptor is deployed, and a replaced WAR is redeployed (with the old session state discarded). By default the WAR is unpacked into a directory of the same name (unpackWARs defaults to true).

Deploy, check and undeploy by file operations
cp target/bookshelf.war "$CATALINA_HOME/webapps/"
grep 'bookshelf.war' "$CATALINA_HOME/logs/catalina.$(date +%Y-%m-%d).log"
curl -s http://localhost:8080/bookshelf/hello
rm "$CATALINA_HOME/webapps/bookshelf.war"      # autoDeploy undeploys it and deletes the unpacked directory
INFO [Catalina-utility-1] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive [.../webapps/bookshelf.war]
INFO [Catalina-utility-1] org.apache.catalina.startup.HostConfig.deployWAR Deployment of web application archive [.../webapps/bookshelf.war] has finished in [141] ms

The scan runs on a timer, so the application may need up to about ten seconds before it answers: the 404 right after the copy and the 200 a few seconds later is normal (in the verification, the new /about page answered 404 immediately after a redeploy and 200 twelve seconds later). Wait for the "has finished" line in catalina.<date>.log rather than guessing.

The file name is the context path. Tomcat derives the name and context path of an application deployed by the host from its file name; the context path may therefore not be defined inside the application’s own META-INF/context.xml.

File name in webapps/ Context path Notes

bookshelf.war

/bookshelf

The usual case.

ROOT.war

/ (the empty context path)

The base name is ROOT, always in upper case. It replaces the default ROOT application; the Docker image below uses it.

shop#catalog.war

/shop/catalog

A # in the file name stands for a / in the context path.

bookshelf##2.war

/bookshelf, version 2

Parallel deployment: several versions of one context path run side by side. New sessions go to the latest version, and existing sessions stay on theirs.

Versions are compared as strings, so bookshelf##11.war is considered earlier than bookshelf##2.war; Tomcat’s documentation recommends zero padding (bookshelf##002.war) when versions are numeric.

The Manager Application

The Manager is a web application that ships in webapps/manager and deploys, reloads and removes other applications over HTTP. Its access is disabled until a user with a manager-* role exists: the default tomcat-users.xml has no such user. The roles are manager-gui (HTML interface), manager-script (the plain-text interface used by tools), manager-jmx and manager-status. The documentation warns that the HTML interface is protected against CSRF but the text and JMX interfaces are not, and recommends that a user holding manager-gui should not also hold manager-script or manager-jmx. The Manager’s META-INF/context.xml also restricts it to local addresses with a RemoteCIDRValve (127.0.0.0/8 and ::1/128 in the shipped file).

conf/tomcat-users.xml: a deployer user (choose your own password; this one is a placeholder)
<tomcat-users>
  <role rolename="manager-script"/>
  <user username="deployer" password="CHANGE_ME" roles="manager-script"/>
</tomcat-users>
Deploying, listing, reloading and undeploying with the text interface
M=http://localhost:8080/manager/text
curl -u deployer:"$MANAGER_PASSWORD" -T target/bookshelf.war "$M/deploy?path=/bookshelf&update=true"
curl -u deployer:"$MANAGER_PASSWORD" "$M/list"
curl -u deployer:"$MANAGER_PASSWORD" "$M/reload?path=/bookshelf"
curl -u deployer:"$MANAGER_PASSWORD" "$M/undeploy?path=/bookshelf"
OK - Deployed application at context path [/bookshelf]
OK - Listed applications for virtual host [localhost]
/bookshelf:running:0:bookshelf
/manager:running:0:manager
OK - Reloaded application at context path [/bookshelf]
OK - Undeployed application at context path [/bookshelf]

(MANAGER_PASSWORD is an environment variable you set yourself; never put the real value in a script that is committed.) The path parameter decides the context path here, regardless of the WAR file’s name, and version=1.0 added to the deploy request produced /bookshelf##1.0 in the listing. A request without valid credentials gets the 401 page that explains which role is missing. In production the Manager is normally not exposed at all (remove it, or keep it behind the RemoteCIDRValve and TLS); this section uses it only to show the command-line deployment.

Context Descriptors

Settings that belong to the server and not to the application, such as a JNDI data source, a session manager or a different docBase, live in a context descriptor: a context.xml file either in $CATALINA_BASE/conf/<engine>/<host>/<name>.xml (outside the WAR) or in the WAR’s own META-INF/context.xml. The deployer guide says that putting Context elements in server.xml still works but is discouraged. Everything about the Context element is in the Tomcat configuration reference; this section’s examples need no descriptor.

Running Without Installing a Server: Maven Plugins

For an edit-run loop, a Maven plugin that starts a container from the build is quicker than copying WARs. Two are in common use: the Jetty plugin, which embeds Jetty, and Cargo, which downloads and runs an installed container.

The Jetty Maven Plugin

Since Jetty 12 the Maven plugin carries the Jakarta EE environment in its coordinates. For EE 11 it is org.eclipse.jetty.ee11:jetty-ee11-maven-plugin, 12.1.14 on 2026-10-08. Declaring it with a context path is enough; the project’s provided APIs are not packaged, and the JSTL jars of WEB-INF/lib are used.

The plugin in pom.xml (inside build, plugins)
<plugin>
    <groupId>org.eclipse.jetty.ee11</groupId>
    <artifactId>jetty-ee11-maven-plugin</artifactId>
    <version>12.1.14</version>
    <configuration>
        <webApp>
            <contextPath>/bookshelf</contextPath>
        </webApp>
    </configuration>
</plugin>
Running and calling it
mvn jetty:run
curl http://localhost:8080/bookshelf/hello          # in another terminal
Hello from Bookshelf on jetty/12.1.14
Servlet API 6.1
Context path /bookshelf

jetty:run runs the Maven build up to compilation, starts Jetty on port 8080 by default (-Djetty.http.port=9090 changed it in the verification) and serves src/main/webapp directly, so a JSP edit is visible without a build. It runs until Ctrl+C. By default it does not restart the web application when classes change; the scan setting turns on redeployment on change. The Jetty documentation does not list extra dependencies to enable JSP: in the verification, /books and /first-steps.jsp rendered with only the plugin above declared.

Cargo

The Codehaus Cargo Maven plugin controls containers from the build: it installs one from a URL, a local directory or Maven Central, deploys the project’s WAR, and starts or stops it. Its tomcat11x container id covers Tomcat 11. With a zipUrlInstaller the first run downloads Tomcat into target/cargo/installs.

The plugin in pom.xml (inside build, plugins)
<plugin>
    <groupId>org.codehaus.cargo</groupId>
    <artifactId>cargo-maven3-plugin</artifactId>
    <version>1.10.29</version>
    <configuration>
        <container>
            <containerId>tomcat11x</containerId>
            <zipUrlInstaller>
                <url>https://archive.apache.org/dist/tomcat/tomcat-11/v11.0.26/bin/apache-tomcat-11.0.26.zip</url>
            </zipUrlInstaller>
        </container>
        <configuration>
            <properties>
                <cargo.servlet.port>8080</cargo.servlet.port>
            </properties>
        </configuration>
        <deployables>
            <deployable>
                <type>war</type>
                <properties>
                    <context>/bookshelf</context>
                </properties>
            </deployable>
        </deployables>
    </configuration>
</plugin>
Running Tomcat 11 from Maven with Cargo
mvn package cargo:run
curl http://localhost:8080/bookshelf/hello          # in another terminal
Hello from Bookshelf on Apache Tomcat/11.0.26
Servlet API 6.1
Context path /bookshelf

The WAR is built first because cargo:run deploys the project’s artifact. The download took a few minutes on the first run and the container stayed in the foreground until it was stopped. Cargo runs a real Tomcat (the same code as the distribution), which is its advantage over an embedded server when a test needs the exact container.

The Same WAR on GlassFish 8

GlassFish needs no descriptor and no extra libraries for this WAR. The commands come from its Quick Start Guide: start the default domain, deploy with asadmin deploy, list, and stop.

Deploying to GlassFish 8
glassfish8/bin/asadmin start-domain
glassfish8/bin/asadmin deploy --contextroot bookshelf target/bookshelf.war
glassfish8/bin/asadmin list-applications
curl http://localhost:8080/bookshelf/hello
glassfish8/bin/asadmin stop-domain
Application deployed with name bookshelf.
Command deploy executed successfully.
bookshelf  <web>
Hello from Bookshelf on Eclipse GlassFish 8.0.5
Servlet API 6.1
Context path /bookshelf

(The check was made with the Web Profile distribution 8.0.5, in a domain created with asadmin create-domain --portbase 18500, so its ports differed from the defaults 8080 and 4848.) One thing differs from Tomcat and Jetty. On GlassFish 8.0.5 the catalogue page failed with jakarta.el.PropertyNotFoundException: The class 'org.example.bookshelf.book.Book' does not have the property 'title': the Book is a Java record, and EL there did not resolve record components, although the Pages 4.0 specification lists jakarta.el.RecordELResolver in the chain of resolvers and EL 6.0 says record support is enabled by default. The same WAR rendered on Tomcat 11.0.26 and on Jetty 12.1.14. Adding JavaBean-style getters to the record (getTitle(), getAuthor(), getYear(), getId()) made the page render on GlassFish too. If an application must run on several servers, give model types getters, or use ordinary classes, until the GlassFish behaviour is fixed.

A Docker Image for the WAR

The official tomcat image on Docker Hub is a Tomcat install in /usr/local/tomcat; tags such as 11.0-jdk21-temurin pin the Tomcat line and the JDK. Two details matter for a WAR. First, the image’s webapps directory is empty: the default applications are kept in webapps.dist (verified: ls /usr/local/tomcat/webapps lists nothing, webapps.dist has ROOT, docs, examples, host-manager and manager), so a copied WAR is the only application. Second, a WAR named ROOT.war is deployed at the root context path, so the container answers at / and not at /bookshelf/.

A multi-stage build keeps Maven and the sources out of the final image: the first stage builds the WAR, the second copies it into a Tomcat image. Only these lines are specific to a WAR; the general mechanics (layers, caches, non-root users, health checks, scanning) are covered by the Docker pages of this site, for example Dockerfile Fundamentals, Build Cache and Multi-Stage Builds, Java and Spring Boot Images and Image Best Practices and Security.

Dockerfile
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
COPY pom.xml .
RUN mvn -q -B dependency:go-offline
COPY src ./src
RUN mvn -q -B package

FROM tomcat:11.0-jdk21-temurin
COPY --from=build /build/target/bookshelf.war /usr/local/tomcat/webapps/ROOT.war
EXPOSE 8080
Building, running and calling the container
printf 'target\n.git\n' > .dockerignore
docker build -t bookshelf-jsp:dev .
docker run -d --rm --name bookshelf -p 8080:8080 bookshelf-jsp:dev
curl http://localhost:8080/hello
curl "http://localhost:8080/books?filter=gib"
docker stop bookshelf
Hello from Bookshelf on Apache Tomcat/11.0.26
Servlet API 6.1
Context path
        <li class="even">Neuromancer by William Gibson (1984)

The context path is empty because the WAR is ROOT.war, which is why every URL lost its /bookshelf prefix. The image built for this check was 533 MB; the official image runs Tomcat as root by default (verified with id -un), so a production image adds a non-root user as the Docker security page describes. If the application needs a database or other services, the Compose and Testcontainers pages of this site show how to start them beside the container.

Worked Example: From an Empty Folder to a Catalogue

The sections above are the explanation; this is the sequence a reader performs, with the check after each step. It uses only files shown on this page.

Step Action Check

1

Install JDK 21 and Maven; set JAVA_HOME.

mvn -version prints Java version: 21....

2

Download, verify and unpack Tomcat 11.0.26; run bin/startup.sh.

curl -I http://localhost:8080/ returns 200; the log has "Server startup in …​".

3

Create bookshelf-jsp/ with the pom.xml and the folders src/main/java and src/main/webapp. No web.xml is needed yet.

mvn -q package ends without errors and creates target/bookshelf.war (a WAR with no descriptor deployed and answered on Tomcat 11.0.26 in the verification).

4

Add HelloServlet and rebuild.

unzip -l target/bookshelf.war lists WEB-INF/classes/.../HelloServlet.class and the two JSTL jars.

5

Copy the WAR to webapps/; wait for the "has finished" log line.

curl http://localhost:8080/bookshelf/hello prints three lines starting Hello from Bookshelf on Apache Tomcat/11.0.26.

6

Add Book, BookService, CatalogueServlet, catalogue.jsp, banner.jspf and rating.tag; rebuild and redeploy.

curl "http://localhost:8080/bookshelf/books?filter=gib" prints one <li> with Neuromancer; no Set-Cookie header is sent (because of session="false").

7

Open /bookshelf/books?filter=%22%3E%3Cscript%3E in the browser or with curl.

The response contains "><script>, not a tag: the filter was escaped by c:out.

8

Request /bookshelf/WEB-INF/jsp/catalogue.jsp.

404: the view is private to the servlet.

9

Edit first-steps.jsp in webapps/bookshelf/ and request it again after a few seconds.

The new text appears without restarting Tomcat (development mode of the JSP servlet).

From here Part I takes the same project further: servlets as controllers with a service layer (MVC with Servlets & JSP), EL and the JSTL libraries in depth (Expression Language Basics, JSTL Core), tag files and tag libraries, forms with validation and sticky fields, internationalisation (JSTL i18n & Messages), security, error pages and a Spring MVC variant of the catalogue page. Part II then starts bookshelf-struts, a second WAR on the same Tomcat built on Apache Struts 7.4; its own Struts Getting Started page builds it by hand with the struts2-bom, because the Struts Maven archetypes stop at 6.0.0, and the two applications can run on one Tomcat side by side because each has its own context path.

Pitfalls

Every row below was reproduced on Tomcat 11.0.26, Jetty 12.1.14 or GlassFish 8.0.5 unless the right-hand column cites the documentation instead.

Symptom Cause Fix

404 for /bookshelf/hello right after copying the WAR

autoDeploy scans on a timer, so the application may not be deployed yet; the first /about request after a redeploy answered 404 and the next ones, twelve seconds later, 200.

Wait for the "Deployment of web application archive …​ has finished" line in catalina.<date>.log.

404 for /hello without the context path, or the right path with another name

The context path is the WAR’s file name, not the Maven artifactId or the display-name.

Set <finalName>, or deploy as ROOT.war for /, or use the Manager’s path parameter.

A WAR deploys without errors but its servlet answers 404

The classes import javax.servlet.*. Tomcat 10 and later implement jakarta.servlet.* only; a javax @WebServlet is not recognised (verified: the WAR deployed, /old answered 404, nothing was logged).

Recompile against jakarta.*, or convert the binary with the Tomcat migration tool (migrate.sh, or the legacyAppBase attribute of the Host).

java.lang.UnsupportedClassVersionError: ... class file version 69.0 ... only recognizes class file versions up to 65.0

The classes were compiled for Java 25 (69) and Tomcat runs on Java 21 (65).

Compile with maven.compiler.release equal to the runtime (21), or run Tomcat on the newer JDK.

JSP fails with Unable to get JAR resource [/WEB-INF/jsp/jakarta.tags.core] containing TLD

uri="jakarta.tags.core" cannot be resolved because the JSTL implementation is not in WEB-INF/lib; Tomcat then treats the URI as a path. Later requests report ClassNotFoundException: org.apache.jsp.WEB_002dINF.jsp.catalogue_jsp.

Add jakarta.servlet.jsp.jstl-api and the org.glassfish.web:jakarta.servlet.jsp.jstl implementation with default scope, and rebuild with clean.

The WAR is large and holds jakarta.servlet-api-6.1.0.jar

The Servlet, Pages or EL API was added without provided. Tomcat ignored the bundled copy silently (verified: the application worked and nothing was logged), so the problem shows only as size and as confusion when versions differ.

Use scope provided; check unzip -l for stray jars.

A JSP under src/main/webapp is requested directly and runs

Anything outside WEB-INF is public (/first-steps.jsp returned 200).

Keep views under WEB-INF/jsp and forward to them from a servlet; the same request for /WEB-INF/jsp/catalogue.jsp answered 404.

Set-Cookie: JSESSIONID=... on a page that does not use a session

A JSP participates in a session by default (session="true"): /books sent the cookie before session="false" was added, and none afterwards.

Add session="false" to the page directive of pages that do not need a session, or set it for all pages in <jsp-config> (Configuration and Property Groups).

GlassFish: jakarta.el.PropertyNotFoundException ... does not have the property 'title' on a record

EL on GlassFish 8.0.5 did not resolve record components in JSP, unlike Tomcat 11.0.26 and Jetty 12.1.14.

Add JavaBean getters to the record, or use a class, if the WAR must run on GlassFish.

maven-archetype-webapp produces a project that compiles for Java 8 with a Servlet 4.0 descriptor

The archetype (1.5) still targets the javax era.

Replace the descriptor with the Servlet 6.1 one and set maven.compiler.release to 21.

A JSP edit does not show up, or a Java edit does not show up

A JSP is recompiled when its file changes, but a class change needs the context to be reloaded or redeployed; and JSP checking is a development-mode feature.

Redeploy the WAR (copy it again, or use the Manager’s reload); in production tune Jasper’s development and checkInterval parameters as the Jasper How-To describes.

401 from /manager/text/...

No user with the manager-script role is configured, or the credentials are wrong; the shipped tomcat-users.xml has no manager users.

Add a user as shown above, restart, and use the placeholder-free password from your own environment variable.

Parallel versions app##11.war and app##2.war pick the "wrong" latest

Versions are compared as strings, so 11 sorts before 2.

Zero-pad numeric versions (##002, ##011).

docker run serves a blank or default page at /

The WAR was copied to webapps/bookshelf.war, so the application is at /bookshelf/, or the image’s webapps is empty and nothing was copied.

Name the file ROOT.war for the root context path.

JSP pages in an executable Spring Boot JAR do not work

The Spring Boot reference lists JSP limitations: JSPs are not supported in an executable jar, and it advises avoiding JSPs where possible.

Package a WAR (see the Spring Boot pages of this site) or choose Thymeleaf.

What Changed Recently

  • Tomcat 11.0 is the Jakarta EE 11 line. It implements Servlet 6.1, Pages 4.0 and EL 6.0 on Java 17 or later; 11.0.26 is the latest release on 2026-10-08. Tomcat 10.1.60 implements Servlet 6.0 and Pages 3.1 (Jakarta EE 10) and Tomcat 9.0.x keeps the javax.* namespace; its end of support is "no earlier than 31 March 2027". Tomcat 10.0.x reached end of life on 2022-10-31.

  • Servlet 6.1. New methods on HttpServletResponse choose the redirect status code and body; the error dispatch gains the jakarta.servlet.error.query_string attribute; containers must support HTTPS; jakarta.servlet.request.secure_protocol is a required attribute for secure requests. Servlet 6.0 had already removed SingleThreadModel (Servlet Fundamentals covers this).

  • Pages 4.0. The minimum Java version is 17; the isThreadSafe page attribute and the jsp:plugin action, both deprecated in 3.1, were removed; the deprecated jakarta.servlet.jsp.el classes were removed; ErrorData gained the query string and the HTTP method of the failed request. Pages 3.1 had added an option to raise PropertyNotFoundException for unknown identifiers (errorOnELNotFound).

  • Expression Language 6.0 made Java 17 the minimum and added RecordELResolver (on by default), an Optional resolver and the length property for arrays.

  • Jakarta Tags 3.0. The URIs are jakarta.tags.core, jakarta.tags.fmt, jakarta.tags.functions, jakarta.tags.sql and jakarta.tags.xml. The implementation jar org.glassfish.web:jakarta.servlet.jsp.jstl is still 3.0.1 (2023-01-19) on Maven Central; a 3.1 milestone exists but no final release. The old URIs http://java.sun.com/jsp/jstl/core and http://xmlns.jcp.org/jsp/jstl/core are still accepted by this implementation (verified on Tomcat 11.0.26).

  • Jetty 12. One module set per Jakarta EE environment, hence the ee11 in the plugin’s coordinates; Jetty 12.1.14 is current and Jetty 11 is end of life.

  • GlassFish 8.0 is the Jakarta EE 11 release, with virtual thread support among the items its release announcement highlights; 8.0.5 is on Maven Central.

  • Jakarta EE Tutorial. The tutorial lists "Jakarta Pages" under Archived in the Web Profile, with the note that it "has been archived in favor of Facelets". Pages is still part of the Jakarta EE 11 Web Profile and of Tomcat 11, and it is what the many existing applications run on.

  • Tomcat security fixes touch JSP directly. Tomcat’s security pages list, for the 11.0 line, CVE-2024-52318 ("Incorrect JSP tag recycling leads to XSS", fixed in 11.0.1), CVE-2024-50379 and CVE-2024-56337 (a time-of-check time-of-use race in JSP compilation, fixed in 11.0.2) and CVE-2025-24813 (path equivalence in partial PUT, fixed in 11.0.3); run the latest 11.0.x, which is 11.0.26 today.

  • Tomcat ships the migration tool. lib/jakartaee-migration-1.0.12-shaded.jar and bin/migrate.sh convert javax binaries; the Host’s legacyAppBase attribute names the directory whose applications are converted on deployment.

Where the Books Are Outdated

Two of the section’s reference books cover this ground: Web Development with JavaServer Pages (2nd edition, JSP 1.2) for the first steps, how JSP works and deploying, and JSTL in Action (JSTL 1.0) for the tag libraries; the section bibliography lists them. Their opening chapters introduce what a servlet container, a servlet and a JSP are, and those concepts are unchanged, but the platform they were written for has changed beyond recognition:

Area The books (as consulted) Current at 2026-10-08

Platform

J2EE 1.3 or 1.4, Servlet 2.3 or 2.4, JSP 1.2 or 2.0, JDK 1.2 to 5, Tomcat 4 or 6, the javax.* packages

Jakarta EE 11 with Servlet 6.1, Pages 4.0, EL 6.0, Java 21 (17 is the minimum), Tomcat 11 and the jakarta.* packages. Tomcat’s own version table lists Tomcat 4.1.x (Servlet 2.3) and 6.0.x as archived

web.xml

The Servlet 2.3 DTD and a <taglib> element directly under <web-app>

The Servlet 6.1 schema (web-app_6_1.xsd), annotations instead of most entries, and TLDs discovered automatically in the META-INF of jars

JSTL URIs

http://java.sun.com/jstl/core and the _rt twin libraries

jakarta.tags.core and one library per area; the http://java.sun.com/jsp/jstl/* URIs are still accepted by the 3.0 implementation (verified)

Scripting

Scriptlet-centric pages as the norm, with <%= %> for output

EL and JSTL; scriptlets still compile (verified) but are the exception, and a deployment descriptor can forbid them

Client formats

WML and WAP for phones, application/x-msexcel as a typical non-HTML example

HTML5, JSON and CSV or XLSX downloads

Removed JSP features

jsp:plugin and applets, the isThreadSafe page attribute

Both removed in Pages 4.0

Setup advice of the Tomcat 4 and 5 era has also changed, as Tomcat’s current guides show. Tomcat’s deployer guide says that Context elements used to be stored in server.xml and that this is now discouraged, in favour of a WAR in webapps/ or a context descriptor. The single manager role of early Tomcat versions was replaced from Tomcat 7 by four roles (manager-gui, manager-status, manager-script, manager-jmx), and the Manager is unusable until a user holds one of them. The <jsp-config> and <taglib> descriptor elements and the other web.xml details of the books' deployment chapters are covered by Servlet Fundamentals and Part I.

The code samples of chapters 2 to 4 of the JSP book contain typographic (curly) quotation marks that make them invalid if copied; every example on this page was typed from the official documentation and compiled. Later pages note the outdated details next to the concepts they touch, and What’s Changed & Migration collects them.

References