What is Apache Tomcat — and Why Does it Matter Specifically?
The previous page described the Servlet Container as an abstract
contract — a specification any runtime can implement. Apache
Tomcat is the concrete, most widely deployed implementation of
that contract: an open-source project under the Apache Software
Foundation implementing the Jakarta Servlet, Jakarta Server Pages, and
Jakarta WebSocket specifications. Writing a compliant
@RestController or a raw HttpServlet is only
half the picture — something has to actually be the running process that
hosts it, and for the large majority of Java web applications today,
that something is Tomcat, whether installed standalone or embedded
inside a Spring Boot JAR.
// BEFORE — a perfectly correct, compliant Servlet with nothing running it
public class OrderStatusServlet extends HttpServlet { /* ... */ }
// Compiled, packaged into a WAR — and going nowhere until some concrete
// implementation of the Servlet spec agrees to load and run it.
// AFTER — Tomcat is that concrete implementation
$ ./bin/startup.sh
$ cp target/myapp.war /opt/tomcat/webapps/
// Tomcat detects the WAR, extracts it, instantiates every Servlet inside it
// according to the exact lifecycle covered on the previous page, and the
// application is live at http://localhost:8080/myapp
Quick facts
- Origin: Donated to the Apache Software Foundation in 1999, originally from Sun Microsystems — it became the reference implementation for the Servlet/JSP specifications
- Written in: Java — runs anywhere a compatible JVM does
- Default ports: 8080 (HTTP), 8443 (HTTPS), 8005 (shutdown listener)
- Why it's the default: free, extensively documented, production-proven at massive scale, and the embedded server Spring Boot ships with out of the box
Version, Java baseline, and package namespace
| Tomcat version | Minimum Java | Servlet spec | Package namespace |
|---|---|---|---|
| Tomcat 8.5 | Java 7+ | Servlet 3.1 | javax.servlet.* |
| Tomcat 9 | Java 8+ | Servlet 4.0 | javax.servlet.* |
| Tomcat 10 | Java 11+ | Servlet 5.0/6.0 | jakarta.servlet.* |
| Tomcat 11 | Java 17+ | Servlet 6.1 | jakarta.servlet.* |
javax/jakarta split, and why it matters here specificallyThe mechanics and history of this migration are covered in full on
Servlet Containers. The
practical consequence for Tomcat specifically: if your application
still imports javax.servlet.*, you need Tomcat 9 or
earlier, or a full migration of your own code and every dependency to
the jakarta namespace before it will run on Tomcat 10+.
Directory Structure
apache-tomcat-10.1.x/
├── bin/ ← startup/shutdown scripts
│ ├── startup.sh / .bat (start)
│ ├── shutdown.sh / .bat (stop)
│ ├── catalina.sh (main script, more options — "catalina.sh run" for foreground output)
│ └── setenv.sh (YOUR custom JVM options go here — never edit catalina.sh itself)
│
├── conf/ ← configuration files
│ ├── server.xml (main server configuration)
│ ├── context.xml (default context/resource settings, shared by every app)
│ ├── tomcat-users.xml (Manager app credentials)
│ └── logging.properties
│
├── lib/ ← Tomcat's own JARs (servlet-api, jsp-api, etc.)
├── logs/ ← catalina.out is the first place to look for any error
├── webapps/ ← YOUR APPLICATIONS GO HERE — drop a WAR, it auto-deploys
└── work/ ← compiled JSPs, session data — safe to clear
webapps/: drop a WAR here for automatic deploymentconf/: everything configurable;server.xmlis the file you'll edit mostlogs/: the first place to check when anything goes wrong —catalina.outcaptures all console output
Installing and Running Tomcat
Download and extract
# From https://tomcat.apache.org/
tar -xzf apache-tomcat-10.1.x.tar.gz
cd apache-tomcat-10.1.x
chmod +x bin/*.sh
./bin/startup.sh # or: ./bin/catalina.sh run (foreground, output in terminal)
# http://localhost:8080 should now show the Tomcat welcome page
./bin/shutdown.sh
Package manager
# Ubuntu/Debian
sudo apt install tomcat10
# macOS (Homebrew)
brew install tomcat
Setting JVM options the right way
# bin/setenv.sh — create this file yourself; never edit catalina.sh directly,
# since that file is replaced on every Tomcat version upgrade
CATALINA_OPTS="$CATALINA_OPTS -Xms512m"
CATALINA_OPTS="$CATALINA_OPTS -Xmx2048m"
CATALINA_OPTS="$CATALINA_OPTS -XX:+UseG1GC"
CATALINA_OPTS="$CATALINA_OPTS -Dspring.profiles.active=production"
export CATALINA_OPTS
Key Configuration Files
server.xml — the main configuration
<!-- conf/server.xml -->
<Server port="8005" shutdown="SHUTDOWN">
<Service name="Catalina">
<Connector
port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
maxThreads="200"
redirectPort="8443" />
<Engine name="Catalina" defaultHost="localhost">
<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true">
<Valve className="org.apache.catalina.valves.AccessLogValve"
directory="logs" pattern="%h %l %u %t "%r" %s %b" />
</Host>
</Engine>
</Service>
</Server>
tomcat-users.xml — Manager authentication
<!-- conf/tomcat-users.xml -->
<tomcat-users>
<role rolename="manager-gui"/>
<role rolename="manager-script"/>
<user username="deploy-bot"
password="${MANAGER_PASSWORD}"
roles="manager-script"/>
</tomcat-users>
The password attribute above is a plaintext value by default —
acceptable only because the file itself must already be
filesystem-restricted to the user running Tomcat. Two independent
layers of hardening matter for any Manager-enabled install: first,
never grant manager-gui (the human web UI) to an
automated deploy account — use the narrower
manager-script role and restrict it to CI/CD's specific
IP range in context.xml; second, configure a digesting
CredentialHandler on the Realm
(org.apache.catalina.realm.MessageDigestCredentialHandler
with a modern algorithm) so the password stored on disk is never
plaintext in the first place, exactly the same principle covered on
Password Hashing.
And in production, the Manager app should usually not be
network-reachable at all outside a CI pipeline's specific runner.
context.xml — shared resources (this is where JNDI actually lives)
<!-- conf/context.xml — this is the concrete, Tomcat-specific home for the
JNDI resource-binding pattern referenced on the "What is an Application
Server?" page: the server configures the actual connection, application
code only ever looks it up by name. -->
<Context>
<Resource
name="jdbc/shopDB"
auth="Container"
type="javax.sql.DataSource"
driverClassName="org.postgresql.Driver"
url="jdbc:postgresql://db.internal:5432/shop"
username="${DB_USER}"
password="${DB_PASSWORD}"
maxTotal="100" maxIdle="30" maxWaitMillis="10000"/>
</Context>
Deploying Applications
Drop the WAR (simplest, fine for a single standalone instance)
cp myapp.war /opt/tomcat/webapps/
# Tomcat detects it, extracts to webapps/myapp/, deploys, live at /myapp
# Undeploy: remove BOTH the WAR and the extracted directory
rm /opt/tomcat/webapps/myapp.war
rm -rf /opt/tomcat/webapps/myapp
Manager web interface
# http://localhost:8080/manager/html — login with tomcat-users.xml credentials
# Upload a WAR, or start/stop/reload/undeploy an existing application
Older guides commonly reference tomcat7-maven-plugin for
mvn tomcat7:deploy-style workflows — it hasn't kept pace
with current Tomcat versions and sees little maintenance today. The
more common modern paths are: build a container image
(FROM tomcat:10.1-jdk21, COPY target/myapp.war
/usr/local/tomcat/webapps/) and let Kubernetes or your
orchestrator handle the actual deployment, or call the Manager's text
interface directly from a CI pipeline via a plain HTTP request rather
than a dedicated build-tool plugin. If you're on Spring Boot, none of
this applies at all — the embedded server ships inside the JAR and
"deployment" is just running it.
Context paths — the WAR filename determines the URL
webapps/ROOT.war → http://localhost:8080/
webapps/api.war → http://localhost:8080/api
webapps/v2#api.war → http://localhost:8080/v2/api // "#" encodes a nested path segment
Tomcat's Internal Architecture
This hierarchy maps directly onto the request lifecycle stages covered on the "What is an Application Server?" page — here's exactly where each stage lives inside Tomcat's own component model and configuration files.
| Component | Purpose | Configured in |
|---|---|---|
| Server | The entire Tomcat instance — top-level container, exactly one per process | server.xml (root element) |
| Service | Groups one or more Connectors with a single Engine | server.xml <Service> |
| Connector | Listens on a port, handles the network/HTTP protocol layer — a Service can have several (HTTP, HTTPS) | server.xml <Connector> |
| Engine | Routes a parsed request to the correct virtual Host | server.xml <Engine> |
| Host | A virtual host — one server can answer for multiple domains | server.xml <Host> |
| Context | One deployed application — one WAR becomes one Context | context.xml, or META-INF/context.xml inside the WAR itself |
One Server runs one or more Services. Each Service pairs its Connector(s) — the actual network listeners — with exactly one Engine, which processes every parsed request and hands it to the correct Host based on the requested domain. Each Host can run several Contexts side by side — this is the mechanism that lets one Tomcat instance serve several independent applications at once, each with its own isolated classloader.
Virtual Threads in a Standalone Tomcat Install
The Virtual Threads capacity-planning shift covered on the "What is an
Application Server?" page applies to Tomcat itself, not only to Spring
Boot's embedded usage of it. On Java 21+, Tomcat 10.1+ can back its
request-handling Executor with virtual threads instead of a
bounded platform-thread pool.
// If Tomcat is embedded via Spring Boot 3.2+, this is the supported,
// well-documented path — covered in full on the previous pages:
spring.threads.virtual.enabled=true
// For a STANDALONE Tomcat instance (not Spring Boot embedded), the same
// capability is exposed through server.xml's Executor configuration on
// Tomcat 10.1.x+ running on Java 21+. The exact element and attribute names
// have evolved across recent point releases faster than most Tomcat
// configuration — check the release notes for the specific 10.1.x/11.x
// version you're running rather than copying a syntax from an older guide.
Enabling a virtual-thread executor removes the platform-thread ceiling that turns downstream latency into an application-wide capacity wall — it does not make a slow database query or a CPU-heavy report generation any faster. Measure where your actual bottleneck is (thread-pool exhaustion with idle CPU points at blocking I/O; high CPU with a healthy thread pool points elsewhere) before treating this as a general performance lever.
Troubleshooting Common Issues
Tomcat won't start
lsof -i :8080 # Linux/Mac — is the port already taken?
netstat -ano | findstr 8080 # Windows equivalent
tail -f logs/catalina.out # the error is almost always right here
# Common causes: port conflict, JAVA_HOME unset, insufficient file permissions,
# a corrupted WAR that fails to extract
Application won't load
tail -f logs/localhost.YYYY-MM-DD.log
# Common causes: missing dependency in WEB-INF/lib, a malformed web.xml,
# a javax/jakarta package mismatch against this Tomcat version, or the
# application's own database connection failing on startup
Out of memory
# bin/setenv.sh
CATALINA_OPTS="-Xms1024m -Xmx4096m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/tomcat/logs/"
# The Manager app's "Find leaks" tool can help identify a leaking classloader
# after repeated hot redeploys — a common source in development environments
Slow under load with healthy CPU
# This is the classic thread-pool-exhaustion-while-idle signature covered
# in the Virtual Threads section above — check thread pool saturation first
<Connector port="8080" maxThreads="500" /> // a band-aid; diagnose the real bottleneck first
Production Best Practices
✅ Do
- Remove or IP-restrict
examples/,docs/, and the Manager app before going to production - Configure a digesting
CredentialHandlerfor Manager credentials rather than relying on plaintext intomcat-users.xml - Put all custom JVM options in
bin/setenv.sh, never editcatalina.shdirectly — it's replaced on every upgrade - Diagnose thread-pool exhaustion against actual CPU/I/O metrics before raising
maxThreadsas a reflex fix - Version-control your
conf/directory the same way you would application source code - Prefer a container image over a dedicated Maven Tomcat plugin for modern CI/CD pipelines
❌ Don't
- Don't leave the Manager app reachable from the public internet, even with a strong password — restrict by network/IP as a second, independent layer
- Don't assume raising
maxThreadsfixes a slow application — it often just delays the same downstream bottleneck by a bit more headroom - Don't mix a
javax.servlet-based dependency into a Tomcat 10+ deployment and expect a clear compile-time error — it typically fails at runtime instead - Don't hardcode database credentials directly in
context.xml— use environment variable substitution or a secrets manager
Interview Questions
Q: What is Apache Tomcat, and how does it relate to the Servlet specification?
Tomcat is an open-source, Apache Software Foundation project that
implements the Jakarta Servlet, JSP, and WebSocket specifications — it's
the concrete runtime that actually executes code written against those
specs. It also serves as the reference implementation the specifications
themselves are measured against.
Q: What does the WAR filename determine when dropped into webapps/?
The context path — the URL prefix the application is served under.
myapp.war becomes available at
/myapp, while ROOT.war becomes the root
application, served at /.
Q: Where should custom JVM options (heap size, GC flags) be configured, and why not directly in catalina.sh?
In a separate bin/setenv.sh file, which Tomcat's startup
scripts source automatically if present. catalina.sh itself
ships with Tomcat and gets overwritten on every version upgrade — any
customization made directly to it is silently lost the next time Tomcat
is updated.
Q: One Tomcat instance hosts three separate applications, each with its own set of dependencies, some of which conflict on version. How does Tomcat's architecture make this safe?
Each deployed WAR becomes its own Context, and each Context
gets its own classloader in Tomcat's classloader hierarchy — a
web-application classloader that loads from that specific
WEB-INF/lib and WEB-INF/classes, delegating
only shared/common classes up to Tomcat's own classloader. Two
applications can depend on conflicting versions of the same library
without interfering, because each Context's classloader resolves that
library from its own isolated WAR contents rather than a single shared
classpath across the whole server instance.
Q: A production Tomcat instance shows thread-pool exhaustion (all maxThreads busy) while CPU utilization stays low. What's the most likely cause, and what are the two available remedies?
Threads are occupied waiting on something — almost certainly a slow
downstream dependency (a database query, a call to another service) — not
computing, which is why CPU stays low even as the pool saturates. Two
genuinely different remedies exist: fix or bound the actual slow
dependency (add an index, add a timeout, add a circuit breaker so slow
calls fail fast instead of holding a thread indefinitely), or remove the
platform-thread ceiling itself by adopting Virtual Threads on Java 21+, so
a blocked request no longer occupies a scarce pooled resource while it
waits. Raising maxThreads alone treats neither cause — it
only raises the number of concurrently blocked requests the pool can
absorb before the same underlying problem resurfaces at a higher
concurrency level.
Q: Why is context.xml's JNDI <Resource> pattern rarely used in a Spring Boot application, even one embedding Tomcat?
JNDI resource binding solves a specific problem of the external-server
deployment model: the same WAR, deployed unmodified to different
environments, needs each environment's server to supply
environment-specific configuration (a different database URL in dev vs.
prod) without the application code changing. A Spring Boot application
embeds its own server and is built as a single self-contained artifact
per environment (or configured via externalized properties and Spring
profiles at startup) — there's no separately-administered external server
to register a JNDI binding with in the first place, so the problem JNDI
solves doesn't arise the same way. The equivalent goal — same artifact,
environment-specific configuration — is achieved via
application.yml profiles and environment variables instead.