Java Module 21 – Jakarta EE web: Servlets and Tomcat
Your first servlet, web.xml and the WAR layout
Write a servlet, build it into a WAR with Maven, map its URLs with @WebServlet or web.xml, and see why Tomcat never serves anything under WEB-INF.
What you will learn
- Build a WAR with Maven against the provided-scope Servlet 6.1 API
- Map servlets with @WebServlet or web.xml and predict which URL pattern wins
- Explain WEB-INF, WEB-INF/classes and WEB-INF/lib and why WEB-INF is never served
Before you start
What you’ll learn · Before you start
What you will learn
- Build a WAR with Maven against the provided-scope Servlet 6.1 API
- Map servlets with @WebServlet or web.xml and predict which URL pattern wins
- Explain WEB-INF, WEB-INF/classes and WEB-INF/lib and why WEB-INF is never served
Before you start
On this page
A servlet is a Java class that extends HttpServlet and overrides a method per HTTP method, such as doGet for
GET requests. You never call it yourself: the container creates the servlet, and whenever a request’s URL matches the
servlet’s URL pattern, it calls the matching method with a request object to read and a response object to write. To
deploy servlets you package them as a WAR, a web application archive: a ZIP file whose top level holds files that
are served as they are, and whose WEB-INF folder holds the compiled classes in WEB-INF/classes, library JARs in
WEB-INF/lib and the optional deployment descriptor, web.xml. Nothing under WEB-INF is ever sent to a browser. A
servlet gets its URL from the @WebServlet annotation or from a mapping in web.xml. This lesson builds your first
servlet with Maven, deploys the WAR to Tomcat 11 and checks each of those rules against what Tomcat does.
The WAR layout
What goes where in a WAR, and which parts the container serves
Text description of the diagram
The diagram shows clock.war, a ZIP file whose name gives the application its context path, /clock. It holds three parts, from top to bottom.
1. The top level holds files that the container serves as they are, such as index.html, a css folder and images. A request for /clock/index.html returns index.html. 2. META-INF holds MANIFEST.MF and details that build tools add. The container never serves it and answers 404. 3. WEB-INF is never served either, and requests for anything in it get 404. It holds three things: web.xml, the optional deployment descriptor; classes, the folder of your compiled classes, which the application's class loader reads first; and lib, the folder of the JAR files your code needs.
The WAR layout comes from the Servlet specification, so every container reads a WAR the same way:
| Path in the WAR | What it holds | Served to browsers? |
|---|---|---|
index.html, css/, images/ |
Static files and JSPs | Yes, at /clock/index.html and so on |
WEB-INF/web.xml |
The deployment descriptor: servlets, mappings, settings | Never: 404 |
WEB-INF/classes/ |
Your compiled classes, in package folders | Never: 404 |
WEB-INF/lib/ |
JAR files your code needs, loaded after classes |
Never: 404 |
META-INF/ |
MANIFEST.MF and details added by build tools |
Never: 404 |
The specification makes the privacy of WEB-INF a rule: no file in it may be served directly to a client, and a
request for one gets 404. Your servlet code can still read those files, so configuration that the server needs and
visitors must not see belongs there. The file name decides where the application lives: Tomcat deploys clock.war
at the context path /clock.
Your first servlet, built with Maven
Your first servlet answers GET requests for /now with the time of day in India:
package com.example.clock;
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;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
/** Answers GET requests for /now with the time of day in India. */
@WebServlet("/now")
public class NowServlet extends HttpServlet {
private static final ZoneId INDIA = ZoneId.of("Asia/Kolkata");
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm:ss");
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
ZonedDateTime now = ZonedDateTime.now(INDIA);
response.setContentType("text/plain;charset=UTF-8");
PrintWriter out = response.getWriter();
out.printf("Time in %s: %s (UTC%s)%n", INDIA, now.format(TIME), now.getOffset());
out.printf("Answered by the servlet named %s, mapped to %s%n", getServletName(), request.getHttpServletMapping().getPattern());
}
} @WebServlet("/now") declares the servlet and maps it in one line. setContentType comes before getWriter, so
that the writer encodes the text in UTF-8. The POM is a normal Maven POM with two differences from a JAR project:
<?xml version="1.0" encoding="UTF-8"?>
<!-- The clock application: one servlet, packaged as a WAR for Tomcat 11. -->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>clock</artifactId>
<version>1.0.0</version>
<!-- war: package builds target/clock.war instead of a JAR. -->
<packaging>war</packaging>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- The Servlet 6.1 API to compile against. Scope provided: Tomcat supplies it at run time, so it stays out of
WEB-INF/lib. -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.1.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<!-- The WAR's name, and so the application's context path: /clock. -->
<finalName>clock</finalName>
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-clean-plugin</artifactId>
<version>3.5.0</version>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>3.5.0</version>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.16.0</version>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
<plugin>
<artifactId>maven-war-plugin</artifactId>
<version>3.5.1</version>
</plugin>
<plugin>
<artifactId>maven-install-plugin</artifactId>
<version>3.2.0</version>
</plugin>
<plugin>
<artifactId>maven-deploy-plugin</artifactId>
<version>3.2.0</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project> <packaging>war</packaging> makes mvn package run the WAR plugin, which copies src/main/webapp to the top of
the WAR and the compiled classes to WEB-INF/classes. The Servlet API has scope provided: Maven compiles your
first servlet against jakarta.servlet-api 6.1.0 but leaves the JAR out of the WAR, because Tomcat supplies the API
itself at run time. The application’s web.xml declares the same class a second time, under the name ist-clock,
and maps that declaration to /ist:
<?xml version="1.0" encoding="UTF-8"?>
<!-- The deployment descriptor. Annotations already map NowServlet to /now; this file declares the same class a second
time, under its own name, and maps that declaration to /ist. -->
<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">
<servlet>
<servlet-name>ist-clock</servlet-name>
<servlet-class>com.example.clock.NowServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>ist-clock</servlet-name>
<url-pattern>/ist</url-pattern>
</servlet-mapping>
</web-app> The session builds the WAR, lists it, deploys it by copying it into Tomcat’s webapps folder and makes five requests.
from session import run
# Builds the clock WAR with Maven, lists it, deploys it to a fresh Tomcat base on 127.0.0.1:28520 and requests its
# servlet through both mappings, its static page and two files under WEB-INF.
run(
"""
cd clock && mvn -q package
cd clock && jar tf target/clock.war
sh new-base.sh base 28520
cp clock/target/clock.war base/webapps/
$CATALINA_HOME/bin/startup.sh
curl -s http://127.0.0.1:28520/clock/now
curl -s http://127.0.0.1:28520/clock/ist
curl -s -o /dev/null -w "%{http_code} %{content_type}\\n" http://127.0.0.1:28520/clock/
curl -s -o /dev/null -w "%{http_code}\\n" http://127.0.0.1:28520/clock/WEB-INF/web.xml
curl -s -o /dev/null -w "%{http_code}\\n" http://127.0.0.1:28520/clock/WEB-INF/classes/com/example/clock/NowServlet.class
grep -E "Deploying web application archive|has finished" base/logs/catalina.out
$CATALINA_HOME/bin/shutdown.sh
""",
port=28520,
show=("java", "maven", "tomcat"),
) Output
# openjdk version "25.0.4.1" LTS · Apache Maven 3.10.0 · Apache Tomcat 11.0.26
$ cd clock && mvn -q package
$ cd clock && jar tf target/clock.war
META-INF/
META-INF/MANIFEST.MF
WEB-INF/
WEB-INF/classes/
WEB-INF/classes/com/
WEB-INF/classes/com/example/
WEB-INF/classes/com/example/clock/
WEB-INF/classes/com/example/clock/NowServlet.class
WEB-INF/web.xml
index.html
META-INF/maven/com.example/clock/pom.xml
META-INF/maven/com.example/clock/pom.properties
$ sh new-base.sh base 28520
$ cp clock/target/clock.war base/webapps/
$ $CATALINA_HOME/bin/startup.sh
Tomcat started.
$ curl -s http://127.0.0.1:28520/clock/now
Time in Asia/Kolkata: 23:53:00 (UTC+05:30)
Answered by the servlet named com.example.clock.NowServlet, mapped to /now
$ curl -s http://127.0.0.1:28520/clock/ist
Time in Asia/Kolkata: 23:53:00 (UTC+05:30)
Answered by the servlet named ist-clock, mapped to /ist
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://127.0.0.1:28520/clock/
200 text/html
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:28520/clock/WEB-INF/web.xml
404
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:28520/clock/WEB-INF/classes/com/example/clock/NowServlet.class
404
$ grep -E "Deploying web application archive|has finished" base/logs/catalina.out
… INFO [main] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive [base/webapps/clock.war]
… INFO [main] org.apache.catalina.startup.HostConfig.deployWAR Deployment of web application archive [base/webapps/clock.war] has finished in […] ms
$ $CATALINA_HOME/bin/shutdown.sh
This output changes from run to run: The time of day in the two answers changes from run to run.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 build_session.py
The listing is the WAR layout from the diagram: NowServlet.class under WEB-INF/classes in its package folders,
web.xml in WEB-INF, index.html at the top, and no WEB-INF/lib, because the only dependency is provided. Both
URLs answered, from two servlets of one class, named com.example.clock.NowServlet (the default name of an
annotated servlet is its class name) and ist-clock. The static page answered 200 at /clock/, while web.xml and
the class file itself answered 404.
@WebServlet or web.xml
Both declare a servlet and its URL patterns, and an application can use both, as this one does.
@WebServlet on the class |
<servlet> and <servlet-mapping> in web.xml |
|
|---|---|---|
| Where the mapping lives | Next to the code | In one file for the whole application |
| Changing a URL | Recompile the class | Edit the file; no compiling |
| One class at two URLs with different settings | Not possible with one annotation | One <servlet> element per declaration |
| Seeing every URL at once | Search the code | Read one file |
@WebServlet suits small applications and servlets that one team owns. A web.xml suits settings that operators
change, such as initialisation parameters, and applications that want every URL listed in one place. When web.xml
sets metadata-complete="true" on its root element, it becomes the only source, and Tomcat skips scanning the
classes for annotations, which also shortens start-up.
Either way, a URL pattern takes one of five forms. An exact pattern such as /students matches one path. A path
pattern such as /api/* matches everything below a prefix. An extension pattern such as *.csv matches by the end of
the path. The pattern / names the default servlet, and the empty string matches the context root alone.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Which URL pattern wins, tested on eighteen paths Pro: opens with a Pro, Premium or Ultimate pass
A servlet’s URL can be an exact path, a path prefix ending in /*, an extension such as *.csv, the default / or
the empty string for the context root. This part maps one probe servlet with all six kinds and records which
declaration answered eighteen requests, so that you can predict the answer before you deploy.
In this part:
- The probe: six declarations of one servlet, built by hand
- The four rules, worked through the results
- Why /* hides your files and JSPs, reproduced
3 runnable examples (Java, Python, XML) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Deployment failures reproduced, and where Tomcat logs their cause Pro: opens with a Pro, Premium or Ultimate pass
Two common mistakes produce a 404 with nothing in the console: a servlet whose constructor throws, and two servlets mapped to one URL. This part reproduces both next to a fixed version, shows which log file holds each cause, and makes Tomcat report a broken servlet when it deploys instead of when a user first calls it.
In this part:
- Five applications, two mistakes
- Reading the results
- A check list for an unexplained 404
2 runnable examples (Python, XML (context.xml), XML (web.xml), broken/…/ReportServlet.java, early/…/ReportServlet.java, fixed/…/ReportServlet.java) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Exercise: an Asia/Kolkata clock that answers in text or JSON Pro: opens with a Pro, Premium or Ultimate pass
Write a servlet that answers with the date and time in India, as text by default and as JSON when the client asks for it, with a clock you can fix for testing. Five sample checks and a deployment show when it is done.
In this part:
- The task and the starting point
- A solution, checked and deployed
5 runnable examples (Java, Python) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher · Warm-up
What is a WAR file, and why are files under WEB-INF never served to browsers?
A WAR, a web application archive, is a ZIP file with the layout that the Servlet specification defines. Its top level is the document root: pages, style sheets, images and JSPs that the container serves under the application's context path, which Tomcat takes from the file name. WEB-INF holds what the application needs but visitors must not see: web.xml, the deployment descriptor; classes, the compiled classes; and lib, the library JARs, which load after classes. The specification forbids serving any file under WEB-INF and requires a 404, so configuration, compiled code and libraries cannot be downloaded and studied. Servlet code can still read those files through ServletContext.getResource and getResourceAsStream, and a servlet can forward to a JSP kept under WEB-INF, so that the page is reachable only through its controller.
-
Mid to senior
A WAR built against jakarta.servlet-api 6.1.0 passes every test and runs on Tomcat 11. On a server that still runs Tomcat 10.1 it deploys without errors, but one endpoint fails with NoSuchMethodError for HttpServletResponse.sendRedirect(String, int). Why, and how do you stop this reaching production?
-
Fresher to mid
An application maps one servlet to /api/, a second to .json and a third to /. Which servlet answers /api/orders.json, /reports/sales.json and /api? A teammate wants every .json file, including those under /api, served by the second servlet. What do you advise?
Key takeaways
- A servlet extends
HttpServlet; the container creates it and callsdoGetand the other methods for requests that match its URL pattern. - A WAR has served files at its top level and private files in
WEB-INF: the web.xml descriptor,classesandlib. - Build WARs with
<packaging>war</packaging>and the Servlet API in scopeprovided, because the container supplies it. - Map servlets with
@WebServletnext to the code or in web.xml in one place; one class declared twice becomes two servlets.
References
- Jakarta Servlet Specification 6.1 (sections 2.3, 8.1, 10.5, 10.6, 12.1 and 12.2) (Eclipse Foundation)
- Jakarta Servlet 6.1 API (Javadoc) (Eclipse Foundation)
- Apache Tomcat 11: Deployment (application developer guide) (The Apache Software Foundation)
- Apache Tomcat 11 Configuration Reference: The Context Container (failCtxIfServletStartFails) (The Apache Software Foundation)
- Apache Maven WAR Plugin (The Apache Software Foundation)
- Apache Maven WAR Plugin: war:war (failOnMissingWebXml) (The Apache Software Foundation)
- Introduction to the Dependency Mechanism (dependency scopes) (The Apache Software Foundation)
- RFC 9110: HTTP Semantics (section 12.5.1, Accept) (IETF)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress