Deployment & Production
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Apache Struts 7.4.0 (released 2026-10-02) on Apache Tomcat 11.0.26 and Java 21; everything was
checked on 2026-10-08 and 2026-10-10 against the Core Developer Guide pages Performance Tuning, Static Content,
Application Servers, OGNL Cache Configuration and Reloading Configuration, the default.properties and
StrutsConstants of the 7.4.0 struts2-core jar, the Tomcat 11 documentation and the example and archetype resources named
at the end. Every Struts and Tomcat example was built into a Maven WAR (g12prod.war and a family of smaller scratch WARs)
and exercised with curl on Tomcat 11.0.26, on a private Tomcat instance with its own CATALINA_BASE so that its
server.xml could be changed; the same WAR was also started on the Jetty 12.1.14 Maven plugin and deployed to GlassFish
8.0.5. The Docker daemon and a Kubernetes cluster were not available for this page, so the Docker and Kubernetes files are
shown as the part that is specific to a Struts WAR and are labelled as not run; the Kubernetes manifest was only parsed.
A Struts application is an ordinary WAR. "Going to production" therefore means three things: switching off what exists only
to help the developer, letting the right component serve each kind of request (static files by the container or a proxy,
actions by Struts), and applying the usual container hardening. The running scenario is the Bookshelf, a catalogue of
books exposed as Struts actions; here it is built once as a production WAR and smoke-tested the way a deployment pipeline
would. The pages on testing, debugging and logging, security hardening, security headers and security bulletins cover the neighbouring topics.
From Development to Production: What Changes
Struts 7.4.0 ships with production-safe defaults, and the first thing to know is how little there is to switch off. The
table lists the settings that distinguish a development run from a production run, with the 7.4.0 default (read from
default.properties) and the value this page uses.
| Setting | 7.4.0 default | Production | Why |
|---|---|---|---|
|
|
|
On, it reloads configuration and resource bundles, turns on the |
|
|
|
Re-reads XML configuration when a file changes; "not recommended for production use" |
|
|
|
Re-reads resource bundles on each request |
|
not set (FreeMarker’s 5 s) |
a large value such as |
How often a template file is checked for changes; templates do not change in production |
|
|
|
Struts can serve its own files from the jar, but the container "is not optimized" for it |
|
|
|
Keeps the browser cache headers on files that Struts serves |
|
|
|
The bounded cache of parsed OGNL expressions and of bean information |
|
|
|
A bound on the length of an OGNL expression; a security setting, kept |
|
|
|
Security settings, never switched off to fix a development problem |
|
|
|
Dynamic method invocation stays off |
The development-mode behaviour, the constants one by one and the load order of the files that set them are described in
Configuration: struts.xml and Constants (see its
section on reloading and development mode);
the debugging side of devMode belongs to the debugging page of this section. What this page adds is everything around
the WAR: where each request is handled, which headers it carries, which container features to switch on and how the WAR
is packaged.
Who Handles Which Request
In production a request passes through more components than on a developer’s laptop, and the useful question for most problems is which of them answers. The figure shows the path of the Bookshelf WAR of this page.
The proxy and the connector are the only components that can see the original scheme and client address, which is why the
valve at the start of the Tomcat pipeline restores them (see Behind a Reverse Proxy). The filter mapped to /* sees every request
that reaches the web application and decides in two steps: if the URL starts with the static content path and Struts serves
static content, it answers itself; otherwise it asks the action mapper for an action. A request with no action falls
through to the next filter and, finally, to a servlet; that is how assets/app.css is served by Tomcat’s default servlet
in the verification below, and also why an extensionless servlet URL needs the exclusion of Health Checks and Startup Failure.
The Bookshelf Production WAR
All the examples on this page are files of one Maven project, g12prod, built from the Bookshelf of the Struts Getting Started
page of this section (the Struts BOM, struts2-core, the Jakarta Servlet and Pages APIs as provided dependencies, Log4j 2).
It adds a small servlet for health checks, static assets, two error pages and an action that always fails, so that every
production behaviour can be requested.
| File | Role |
|---|---|
|
The production constants and the |
|
The Struts filter, an |
|
Logging at |
|
The |
|
The files of the Struts jar, extracted so that the container serves them |
|
The application’s own stylesheet |
|
The catalogue page; |
The struts.xml is the heart of the production configuration. Each constant is explained in the sections that follow.
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE struts PUBLIC
"-//Apache Software Foundation//DTD Struts Configuration 6.0//EN"
"https://struts.apache.org/dtds/struts-6.0.dtd">
<struts>
<!-- production: no reloading, no development mode (these are the defaults, stated on purpose) -->
<constant name="struts.devMode" value="false"/>
<constant name="struts.configuration.xml.reload" value="false"/>
<constant name="struts.i18n.reload" value="false"/>
<!-- static content is served by the container, not by Struts -->
<constant name="struts.serve.static" value="false"/>
<!-- let the servlet at /health and the files under /assets bypass Struts -->
<constant name="struts.action.excludePattern" value="/health,/assets/.*"/>
<!-- templates do not change in production: check for changes every hour -->
<constant name="struts.freemarker.templatesCache.updateDelay" value="3600"/>
<!-- caches: type and size (7.4.0 defaults: wtlfu, 10000) -->
<constant name="struts.ognl.expressionCacheType" value="wtlfu"/>
<constant name="struts.ognl.expressionCacheMaxSize" value="10000"/>
<package name="default" extends="struts-default" namespace="/">
<action name="books" class="org.example.bookshelf.BookAction">
<result>/WEB-INF/views/books.jsp</result>
</action>
<action name="boom" class="org.example.bookshelf.BoomAction"/>
</package>
</struts>
The web.xml adds Tomcat’s ExpiresFilter ahead of the Struts filter for the two static prefixes, the cookie settings of
the session (Tomcat in Production) and error pages, so that a failing request never shows a container page. The filter order is the
order of the <filter-mapping> elements.
<?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">
<filter>
<filter-name>expires</filter-name>
<filter-class>org.apache.catalina.filters.ExpiresFilter</filter-class>
<init-param>
<param-name>ExpiresByType text/css</param-name>
<param-value>access plus 7 days</param-value>
</init-param>
<init-param>
<param-name>ExpiresByType text/javascript</param-name>
<param-value>access plus 7 days</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>expires</filter-name>
<url-pattern>/static/*</url-pattern>
<url-pattern>/assets/*</url-pattern>
</filter-mapping>
<filter>
<filter-name>struts2</filter-name>
<filter-class>org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>struts2</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<session-config>
<cookie-config>
<http-only>true</http-only>
<secure>true</secure>
<attribute>
<attribute-name>SameSite</attribute-name>
<attribute-value>Lax</attribute-value>
</attribute>
</cookie-config>
</session-config>
<error-page>
<error-code>404</error-code>
<location>/errors/404.html</location>
</error-page>
<error-page>
<error-code>500</error-code>
<location>/errors/500.html</location>
</error-page>
</web-app>
Source: Core Developer Guide — web.xml
Source: Tomcat 11 — Expires Filter
The ExpiresFilter is a Tomcat class, so this web.xml runs on Tomcat only; on Jetty or GlassFish the same effect is
obtained with a different filter or, better, at the proxy. The filter and the session settings are explained with their
verification below. The full request matrix of this WAR is in Worked Example: Smoke-Testing the Bookshelf Production WAR.
Production Constants and Environment-Specific Configuration
Struts 7.4.0 starts in production mode unless told otherwise, and ?debug=xml is the quickest check of that. In the
Bookshelf with struts.devMode set to true the request returns the value-stack dump, and with the default it returns the
normal page (observed on Tomcat 11.0.26).
$ curl -s 'http://localhost:18081/g12deploy/books?debug=xml' | head -c 8 # devMode=true
<debug>
$ curl -s 'http://localhost:18081/g12deploy/books?debug=xml' | head -c 8 # default or false
<!DOCTYPE
Teams that need different settings per environment have four mechanisms to choose from.
| Mechanism | Result (observed) | Comment |
|---|---|---|
A filter |
Works: |
Static: it lives in |
An environment variable referenced in |
Works: |
Build once, deploy everywhere: the same WAR reads the setting from the environment of each JVM, and the default after the colon is the safe one (Configuration: struts.xml and Constants has the syntax) |
A Maven profile that filters a |
Works: the |
One WAR per environment, which defeats "build once, deploy everywhere" |
|
Works only when the constant is referenced as |
Struts constants are not read from system properties directly; |
The Maven variant needs only a filtered resource and a profile.
struts.devMode=${struts.devMode}
struts.configuration.xml.reload=${struts.devMode}
<properties>
<struts.devMode>false</struts.devMode>
</properties>
<profiles>
<profile>
<id>dev</id>
<properties>
<struts.devMode>true</struts.devMode>
</properties>
</profile>
</profiles>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>struts.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<excludes>
<exclude>struts.properties</exclude>
</excludes>
</resource>
</resources>
</build>
mvn package then produced a struts.properties containing struts.devMode=false and mvn package -Pdev one containing
struts.devMode=true; both WARs were deployed side by side and only the second answered ?debug=xml. Keep the filtered file
tiny, because filtering replaces every ${...} in it, and filter only that file as the pom.xml above does. The init-params
the Struts filter itself understands (config, configProviders and others) are listed in
The Filter and Its Init-Params in web.xml.
Secrets, such as database passwords, do not belong in any of these files; the Kubernetes and Docker pages of this site
(Secrets, ConfigMaps) show how the container
platform injects them.
Static Content
Struts can serve static files itself. The Core Developer Guide describes the arrangement: the constant
struts.ui.staticContentPath (default /static) names a path prefix, the StaticContentLoader (default
DefaultStaticContentLoader) answers requests under it from the classpath, and the same path serves the files that the
xhtml and css_xhtml themes need (s:head emits links to them). The Bookshelf shows the default arrangement first.
$ curl -s -D - -o /dev/null http://localhost:18081/g12deploy/static/utils.js
HTTP/1.1 200
Expires: Sun, 11 Oct 2026 11:05:50 GMT
Retry-After: Sun, 11 Oct 2026 11:05:50 GMT
Cache-Control: public
Last-Modified: Sat, 10 Oct 2026 11:05:45 GMT
Content-Type: text/javascript
Struts adds Cache-Control: public and an Expires one day ahead, the browser-cache behaviour that
struts.serve.static.browserCache=true stands for; the Retry-After header with the same date is sent too and means
nothing for a 200. With struts.serve.static.browserCache=false the answer carries Cache-Control: no-cache,
Pragma: no-cache and Expires: -1 instead, which is useful while the static files of a plugin change. Both forms are
verified on 7.4.0.
Changing or Disabling the Path
Two constants move or switch off the arrangement. Moving the path is a one-line constant; the old path then answers 404 and the new one 200 (observed).
<constant name="struts.ui.staticContentPath" value="/assets-struts"/>
Disabling it is the production choice when the container, a CDN or a proxy serves the files.
<constant name="struts.serve.static" value="false"/>
With the constant false a request for /static/utils.js answers 404 (observed), but the tags do not know that: s:head
still writes the links, so a page that uses the xhtml theme and s:head needs the files to exist somewhere. The Core
Developer Guide says so ("make sure the JavaScript and CSS files shipped inside the core jar are extracted to your web
application directory"). Extraction is a build step. The files are not under static/ in the jar; they are under
org/apache/struts2/static/ and, for the theme stylesheets, template/<theme>/styles.css, and the loader maps them to the
URLs below, so the copy has to reproduce the URLs.
J=$HOME/.m2/repository/org/apache/struts/struts2-core/7.4.0/struts2-core-7.4.0.jar
mkdir -p tmp && (cd tmp && unzip -q "$J" "org/apache/struts2/static/*" \
"template/xhtml/styles.css" "template/css_xhtml/styles.css")
D=src/main/webapp/static
mkdir -p $D/xhtml $D/css_xhtml
cp tmp/org/apache/struts2/static/* $D/
cp tmp/template/xhtml/styles.css $D/xhtml/
cp tmp/template/css_xhtml/styles.css $D/css_xhtml/
rm -rf tmp
After this copy and with struts.serve.static set to false, the page produced by s:head linked
/g12prod/static/xhtml/styles.css and /g12prod/static/utils.js, and Tomcat’s default servlet answered both with 200 and,
through the ExpiresFilter of The Bookshelf Production WAR, Cache-Control: max-age=604800 and an Expires seven days ahead
(observed). The application’s own files under assets/ get the same headers.
$ curl -s -D - -o /dev/null http://localhost:18081/g12prod/static/utils.js
HTTP/1.1 200
Cache-Control: max-age=604800
Expires: Sat, 17 Oct 2026 11:26:41 GMT
Content-Type: text/javascript;charset=ISO-8859-1
The charset=ISO-8859-1 on a JavaScript file comes from Tomcat’s default servlet and its file-encoding default, not from
Struts; a proxy or an explicit <mime-mapping> and fileEncoding setting changes it, and it is harmless for the
ASCII-only files of the jar. A seven-day lifetime is safe for files whose names change when the content changes (a build
hash, a version directory) and a risk for app.css under a fixed name: a long max-age means returning visitors keep the
old file, so production builds either fingerprint the name or use a short lifetime with validation (ETag, Last-Modified),
which Tomcat’s default servlet sends for files on disk.
Source: MDN — HTTP caching
Source: Tomcat 11 — Expires Filter
Keeping Requests Away from Struts
Files and servlets of the application itself are not served by the Struts loader. They pass through the Struts filter,
which tries to map the URL to an action and, finding none, lets the request continue. The mapping step is where a surprise
sits: the 7.4.0 default struts.action.extension is action,,, so an extensionless URL is a candidate action name. A file
with an extension (/assets/app.css) passes; an extensionless servlet URL (/health) does not.
$ curl -s -i http://localhost:18081/g12deploy/health | head -1
HTTP/1.1 404
$ curl -s http://localhost:18081/g12deploy/health | grep -o "There is no Action mapped[^.]*"
There is no Action mapped for namespace [/] and action name [health] associated with context path [/g12deploy]
The remedy is the exclude pattern: a comma-separated list of regular expressions matched against the request URI, which makes the filter pass matching requests untouched.
<constant name="struts.action.excludePattern" value="/health,/assets/.*"/>
With it /health answered 200 UP and /assets/app.css 200 (observed). Since Struts 6.1.0 a separator other than the comma
can be chosen with struts.action.excludePattern.separator when a pattern itself needs a comma. The expressions are Java
regular expressions, matched with the whole URI including the context path, so write them with care and prefer a narrow
pattern to .*.
WebJars
Since Struts 7.3.0 the same pipeline serves WebJars, the client-side libraries (Bootstrap, jQuery) packaged as JARs under
META-INF/resources/webjars/<name>/<version>/. The core jar bundles webjars-locator-lite, and the webjar tag writes the
URL. The Bookshelf added org.webjars:bootstrap:5.3.8 and one tag.
<link rel="stylesheet" href="<s:webjar path="bootstrap/css/bootstrap.min.css"/>">
$ curl -s http://localhost:18081/g12wj/books | grep -o 'href="[^"]*bootstrap[^"]*"'
href="/g12wj/static/webjars/bootstrap/5.3.8/css/bootstrap.min.css"
$ curl -s -o /dev/null -w "%{http_code}\n" .../static/webjars/bootstrap/5.3.8/css/bootstrap.min.css
200
$ curl -s -o /dev/null -w "%{http_code}\n" .../static/webjars/bootstrap/css/bootstrap.min.css
200
Several details follow from the run. The tag writes the versioned URL, although the page describes version-less requests;
both forms are served. WebJars ride the static pipeline, so they need struts.serve.static to be true: with false both
URLs answered 404, and a WebJar application that disables Struts static serving must let the container serve the jar’s
META-INF/resources itself (the Servlet specification makes a container serve it for jars in WEB-INF/lib, under
the path /webjars/...; that route was not run here). struts.webjars.allowlist restricts the
names: with jquery as the allowlist the Bootstrap URL answered 404 and the tag wrote an empty href. Path traversal
(.. and its encoded form) answered 404. The constants are struts.webjars.enabled (default true) and
struts.webjars.allowlist (default empty, meaning every WebJar on the classpath).
Source: Tag Developer Guide — webjar Tag
The theme side of static content (s:head, the stylesheet of each theme, overriding templates) is in
Themes and Templates. For a high-traffic site the guide’s advice is
the same as for any web application: move the files to a separate server or a CDN and let a cache layer in front of
Tomcat serve them, which is what struts.serve.static=false makes possible.
Performance Tuning
The Performance Tuning page of the Core Developer Guide is a list of tips written for versions between 2.0 and 2.5, with newer entries added later. The table below goes through each tip and says what is true of 7.4.0; the sections after it show the measurements.
| Tip | Status in 7.4.0 | Evidence or advice |
|---|---|---|
Turn off logging and |
Valid |
|
Use the Java templates |
Valid, as a plugin |
|
Do not use interceptors you do not need |
Valid, with a security cost |
A leaner stack also drops the security-header interceptors (A Leaner Stack Has a Price) |
Use the correct HTTP headers ( |
Valid |
|
Copy static content when using the Ajax theme (Dojo) or the Calendar tag |
Obsolete |
7.4.0 ships the |
Create a |
Replaced by a constant |
Use |
Enable FreeMarker template caching with |
Removed |
Only a commented line in |
FreeMarker whitespace stripping (since 7.2.0) |
Valid, on by default |
|
|
Valid, on by default |
|
Copy all templates when overriding a theme |
Valid |
A template missing in the custom theme is looked for in the parent theme first; see Themes and Templates |
Do not create sessions unless you need them |
Valid |
|
Use FreeMarker syntax instead of the JSP tags |
Valid |
A small cost per tag; relevant only when the views are FreeMarker, see FreeMarker and Velocity Views |
The OGNL 3.0.3 drop-in for versions before 2.3 |
Obsolete |
7.4.0 pins OGNL 3.4.12 |
The OGNL Caches
Struts keeps two caches for OGNL, one for parsed expressions and one for BeanInfo objects, and 7.4.0 has caches for
localized text and proxy detection with the same constants. The OGNL Cache Configuration page documents struts.ognl.enableExpressionCache,
struts.ognl.expressionCacheMaxSize, struts.ognl.beanInfoCacheMaxSize and two LRUMode constants. In 7.4.0 the LRUMode
constants do not exist; the cache implementation is chosen by a type constant instead.
| Constant | Default | Meaning |
|---|---|---|
|
|
Toggle for the parsed-expression cache (since 6.0.0); disabling saves memory and costs speed |
|
|
|
|
|
Maximum entries of the expression cache |
|
|
The same for |
|
|
The localized-text caches |
|
|
The proxy-detection caches |
|
not set |
A custom |
The defaults are good for most applications; change them only when a measurement says so, for example when the heap is small (lower the sizes) or when a very large number of distinct expressions is generated from request data (a design problem worth fixing first). A value that is not a cache type is a startup failure, not a warning.
<!-- struts.xml: expressionCacheType=lru, beanInfoCacheType=lru, sizes 2000 -->
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:18081/g12deploy/books
200
<!-- struts.xml: expressionCacheType=bogus -->
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:18081/g12deploy/books
404
$ grep "Caused by" base/logs/catalina.out | tail -1
Caused by: java.lang.NullPointerException: Cannot invoke "org.apache.struts2.ognl.OgnlCacheFactory$CacheType.ordinal()" because "cacheType" is null
The 404 is not a missing action: the filter failed to initialise, Tomcat reported One or more Filters failed to start
and Context [/g12deploy] startup failed, and every URL of the context, including a plain servlet and the static files,
answered 404. The old struts.ognl.expressionCacheLRUMode and struts.ognl.beanInfoCacheLRUMode constants from the
documentation are accepted and silently ignored (the WAR deployed and answered 200), so a configuration copied from an
older page keeps working and keeps using the default cache.
The FreeMarker Template Cache
FreeMarker caches compiled templates and, on each lookup, checks how long ago it last asked whether the template file
changed. The constant struts.freemarker.templatesCache.updateDelay sets that interval in seconds. A change to a template that
lives in the web application (for example a copy of template/simple/text.ftl under the web root, the way
Themes and Templates overrides templates) shows how it behaves. The
test edited the template and polled the page.
struts.freemarker.templatesCache.updateDelay |
Edit visible after |
|---|---|
not set |
5.1 s every time |
|
1.1 s |
|
not within 60 s |
The default is therefore FreeMarker’s 5 seconds, not the 500 ms of the Performance Tuning page, and the unit is seconds. In production set the constant to a large value so that FreeMarker stops touching the file system for every template on every request; templates inside the jar and inside the WAR do not change until the next deployment anyway.
<constant name="struts.freemarker.templatesCache.updateDelay" value="3600"/>
The page’s other entries, struts.freemarker.templatesCache and a freemarker.properties file, are the pre-2.3 and
2.0.10 mechanisms and have no effect (the constant exists only as a commented line in default.properties).
struts.freemarker.mru.max.strong.size (default 0, which selects the soft cache storage, "performs better in heavy loaded
applications") and struts.freemarker.beanwrapperCache (default false) are the two other knobs and need no change.
Smaller HTML: Whitespace Stripping, s:compress and gzip
Three mechanisms reduce the size of the HTML. Whitespace stripping (since 7.2.0, struts.freemarker.whitespaceStripping,
default true) removes indentation and trailing whitespace from lines that contain only FreeMarker tags while the tag
templates are compiled. The s:compress tag (since 7.2.0, struts.tag.compress.enabled, default true) removes whitespace
between the tags of its body at run time. Neither touches text. gzip at the connector or proxy works on whatever the others
leave.
| Configuration | Bytes |
|---|---|
|
825 |
default (stripping on) |
638 |
stripping on, |
680 |
stripping on, |
660 |
the final production page, plain |
816 |
the final production page, |
435 |
<s:compress>
<ul>
<s:iterator value="titles">
<li><s:property/></li>
</s:iterator>
</ul>
</s:compress>
<!-- compress disabled -->
<ul>
<li>
Dune
</li>
<li>
Neuromancer
</li>
</ul>
<!-- compress enabled -->
<ul><li>
Dune
</li><li>
Neuromancer
</li></ul>
The measurements show the order of magnitude: stripping saved 187 of 825 bytes on a page that is mostly a form, s:compress
saved 20 bytes on a two-item list, and gzip roughly halved the page. Stripping is applied to the templates of the tags and
costs nothing at run time; s:compress has a run-time cost and protections (a 10 MB body limit, truncated log output,
safeguards against pathological regular expressions: struts.tag.compress.maxSize, struts.tag.compress.log.maxLength)
and is worth it only for large, whitespace-heavy pages. The documentation and default.properties say whitespace stripping
is "automatically disabled when devMode is active"; on 7.4.0 a run with struts.devMode=true (confirmed by ?debug=xml)
still produced the 638-byte, stripped page, so do not rely on it for readable HTML while debugging.
gzip is a connector setting of the container. On Tomcat 11 it is three attributes of the HTTP connector in server.xml.
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443"
compression="on" compressionMinSize="256"
compressibleMimeType="text/html,text/css,text/javascript,application/json" />
A request with Accept-Encoding: gzip then answered Content-Encoding: gzip and Vary: accept-encoding (observed, 816
bytes against 435). When a proxy terminates the connection it normally compresses too, and then the connector setting is
redundant; do one or the other, and take care to list only text types, because compressing an already compressed JPEG or
PDF wastes CPU. Compression over TLS of content that mixes secrets with attacker-controlled text is a known side-channel
class (BREACH); the security hardening page of this section is the place for that decision.
HTTP Caching of Dynamic Pages
The tip "use the correct HTTP headers" is about dynamic HTML. Struts adds no Cache-Control to an action’s response (the
production request matrix below shows none on /books), so the response carries no caching directive at all. A page that
shows personal data must say Cache-Control: no-store, and a public catalogue page can be cached for a short time. The
httpheader result, a servlet filter in web.xml or the proxy can add the header; the filter mapped in front of the Struts
filter is the generic solution and works for any container.
Sessions, I18nInterceptor and the Interceptor Stack
Struts itself does not create a session unless something asks. Two things ask without being noticed. A JSP creates a session
by default (page directive session="true"), so the production books.jsp says session="false". And the i18n
interceptor, which is part of the default stack, stores the locale chosen with request_locale in the session. The Bookshelf
shows it.
$ curl -s -D - -o /dev/null http://localhost:18081/g12deploy/books | grep -i set-cookie
$ curl -s -D - -o /dev/null "http://localhost:18081/g12deploy/books?request_locale=es" | grep -i set-cookie
Set-Cookie: JSESSIONID=<id>; Path=/g12deploy; HttpOnly
The first request has no cookie (the page has session="false" and nothing else touches the session); the second has one.
In this run only the request with request_locale created a session; an application that wants stateless,
cache-friendly pages never sends the parameter or removes the interceptor from the stack (A Leaner Stack Has a Price shows the price). Sessions matter for scaling: each one is memory in the container and, in a cluster, state that
the load balancer has to keep together or the container has to replicate; replicated session attributes must be
Serializable (a servlet-container rule that this page did not run), so keep action objects out of the session and put in
it only the user and a few small values. The sibling topic page,
Action Chaining, Sessions and Scopes, covers what is
legitimate to keep there, and Localization and i18n the interceptor.
A Leaner Stack Has a Price
The defaultStack of 7.4.0 has 24 interceptors, and the tip to use a smaller one is easy to follow with a stack that the
framework already defines: validationWorkflowStack is basicStack plus validation and workflow. The Bookshelf action
was switched to it with one line.
<action name="books" class="org.example.bookshelf.BookAction">
<interceptor-ref name="validationWorkflowStack"/>
<result>/WEB-INF/views/books.jsp</result>
</action>
$ curl -s -D - -o /dev/null "http://localhost:18081/g12deploy/books?request_locale=es"
# defaultStack: Set-Cookie JSESSIONID, Vary: Sec-Fetch-*, Cross-Origin-Embedder-Policy-Report-Only: require-corp,
# Cross-Origin-Opener-Policy: same-origin
# validationWorkflowStack: no cookie, none of those headers
The saving is real (no session, fewer interceptors) and so is the loss. The coop, coep and fetchMetadata
interceptors of the 7.4.0 default stack write the Cross-Origin headers and the Vary header of the Sec-Fetch family and
implement a resource isolation policy based on the fetch metadata, and the csp interceptor belongs to the same family; the debugging interceptor is what answers
?debug= in development. A stack that omits them is a stack without those protections. The security headers page of this
section explains each one; the rule here is that "do not use interceptors you do not need" now includes security
interceptors, and that a custom stack should be built by copying defaultStack and deleting named entries, not by starting
from basicStack. See Interceptors for the full list in order.
Logging
Log level is the tip with the largest effect. Struts and FreeMarker log a great deal at debug. On the Bookshelf, with
org.apache.struts2 and freemarker at debug, one warm request to /books wrote 267 lines to standard output; with no
log4j2.xml and with warn it wrote none. A production log4j2.xml therefore sets the two loggers to warn, the root to
info and writes to the console, which is where Tomcat, Docker and Kubernetes collect logs.
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{ISO8601} %-5level %logger{1} - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Logger name="org.apache.struts2" level="warn"/>
<Logger name="freemarker" level="warn"/>
<Root level="info">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>
Source: Apache Log4j — Configuration
Errors are still logged: the boom action of the production WAR, which throws IllegalStateException, wrote an ERROR
line from DefaultDispatcherErrorHandler with the stack trace and the request got the 500 page of the web.xml (see
Worked Example: Smoke-Testing the Bookshelf Production WAR). The debugging page of this section covers the levels in depth and the logging constants.
Application Servers
The Application Servers page of the Core Developer Guide is a list of products with a few links, written for the era of JRun, OC4J, Orion, Resin and SunOne; it also names JBoss 5, WebLogic, WebSphere, Glassfish 2.x and Wildfly 10.x. None of them is a target for Struts 7, which needs Servlet 6 (Jakarta EE 10 or newer). A Struts 7 WAR is an ordinary Jakarta Servlet 6 application and runs on any container that implements it. Three were run for this page.
| Container | How it was run | Result |
|---|---|---|
Apache Tomcat 11.0.26 |
|
200; every check in this page |
Eclipse Jetty 12.1.14 |
|
200; |
Eclipse GlassFish 8.0.5 (web profile) |
|
200; the JSP engine reports itself as |
Server-specific differences are small and are all in the container, not in Struts: the way static files get their content
type, the default session cookie attributes, how a context path is chosen, the error page the container writes when
Struts does not. Nothing in struts.xml changes. The configuration of the WAR for Jetty is a plugin entry in the
pom.xml.
<plugin>
<groupId>org.eclipse.jetty.ee11</groupId>
<artifactId>jetty-ee11-maven-plugin</artifactId>
<version>12.1.14</version>
<configuration>
<httpConnector>
<port>18082</port>
</httpConnector>
<webApp>
<contextPath>/g12deploy</contextPath>
</webApp>
</configuration>
</plugin>
asadmin --port 18548 deploy --contextroot g12deploygf --name g12deploygf /path/to/g12deploy.war
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:18580/g12deploygf/books
asadmin --port 18548 undeploy g12deploygf
The Jetty plugin is a development and test tool, not a production server; a production Jetty is a Jetty base with the WAR in
its webapps directory, described in the Jetty operations guide. The three-container table is a portability check and a
reason to keep container-specific features (the ExpiresFilter, a Tomcat valve) behind a note in the deployment documentation
of the project. The earlier getting-started pages of this section install the three servers and run the same WAR
(Jetty 12.1 and GlassFish 8 as Alternatives,
Deploying to Tomcat); a javax.servlet WAR needs migration first, as
the pitfalls of that page explain.
Tomcat in Production
Tomcat’s own Security How-To and the Struts pages together give the production changes that the Bookshelf verification
covers. Each is one server.xml or web.xml element.
| Setting | Effect (observed) | Where |
|---|---|---|
Remove the default web applications ( |
The Tomcat Security How-To names them as an information and risk surface; the official Docker image ships an empty |
|
|
A 404 body shrank from a report with the Struts message and the version |
A |
|
The 404 and 500 answered the HTML files of the WAR, and the 500 from the failing action was logged at |
|
|
Restores scheme, host and client address from |
A |
Session cookie |
|
|
Compressing connector |
Content-Encoding gzip, 816 to 435 bytes |
|
JVM flags |
Heap sized from the machine or container memory; a process that runs out of memory exits and is restarted by its supervisor (the flags were read back with |
|
The two valves are plain server.xml elements inside the Host.
<Valve className="org.apache.catalina.valves.ErrorReportValve"
showReport="false" showServerInfo="false" />
<Valve className="org.apache.catalina.valves.RemoteIpValve"
remoteIpHeader="X-Forwarded-For" protocolHeader="X-Forwarded-Proto" />
Source: Tomcat 11 — Error Report Valve
The valve belongs to the Host (or the Engine), not to the web application’s META-INF/context.xml: the Tomcat pipeline
writes the container report before the Host dispatches <error-page> entries, so a valve in the Context pipeline stops
the entries working (the exception handling page of this section records the effect). Per-application error pages and
the Struts exception mappings, which handle the exceptions of actions before the container sees them, are covered in
Exception Handling and Global Results; the 500 page
of web.xml is the safety net for what escapes them. ExitOnOutOfMemoryError assumes something restarts the process (a
service manager, a container restart policy, Kubernetes); without a supervisor it turns an out-of-memory error into an
outage.
Source: Tomcat 11 — Cookie Processor
Context Paths, ROOT.war and Parallel Deployment
Tomcat derives the context path from the file name of the WAR. A WAR named g12deploy.war is served under /g12deploy, a
WAR named ROOT.war at /, and a name with ## before a version string deploys several versions of the same path
side by side, so that a new version can take new sessions while the old one drains. All three were run on the private
instance.
$ cp g12deploy.war base/webapps/ROOT.war
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:18081/books
200
$ curl -s http://localhost:18081/books | grep -o 'action="[^"]*"'
action="/books.action"
$ cp g12deploy.war 'base/webapps/g12pd##001.war' && cp g12deploy.war 'base/webapps/g12pd##002.war'
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:18081/g12pd/books
200
With ROOT.war the form action is /books.action, because Struts builds links from the context path, which is empty; no
URL in the application may hard-code the old prefix. In a parallel deployment Tomcat sends a request without a session to the latest version and
a request with a session to the version that owns it (the rule of the Tomcat documentation); the two WARs are separate web
applications with their own Struts configuration and caches.
Remove the older version after its sessions have ended, and remember that anything with state outside the web application
(a database schema, a shared cache) must work with both versions at once, which is the real constraint of a rolling upgrade.
JSP Compilation in Production
The views of a Struts application are JSP pages, so the Jasper settings of the container matter. The Tomcat Jasper How-To
has a Production Configuration section, and the Jasper page of this section explains every parameter: set
development to false (so that the container stops checking the JSP files for changes), consider precompiling the pages
with JspC so that the first request of each page does not pay for compilation, and keep the work directory writable.
See Jasper Configuration & Precompilation, including
its development and production settings, and the security hardening page of this section for the WEB-INF placement of
views that keeps JSP files from being requested directly.
Behind a Reverse Proxy
A production Struts application is nearly always behind a proxy or load balancer that terminates TLS. The container then
sees plain HTTP from the proxy, and Struts, which builds absolute URLs from the request, would write http:// links. The
s:url tag with forceAddSchemeHostAndPort shows the effect, and RemoteIpValve the fix; the proxy has to send
X-Forwarded-Proto and the host.
$ curl -s http://localhost:18081/g12deploy/books | grep abs
<p id="abs">http://localhost:18081/g12deploy/books.action</p>
$ curl -s -H 'X-Forwarded-Proto: https' -H 'Host: books.example.test' http://localhost:18081/g12deploy/books | grep abs
<p id="abs">https://books.example.test/g12deploy/books.action</p>
The default internalProxies of the valve match loopback and private addresses, which is why the test from the same
machine was trusted; in production the valve’s internalProxies must list exactly the proxies, so that clients cannot send
their own X-Forwarded-For and become someone else’s address in the access log. The constants struts.url.http.port and struts.url.https.port exist for the port
Struts writes into absolute URLs when the valve cannot be used (they were not run here). Other proxy duties are the
proxy’s own: TLS, HTTP/2, the large-upload limit (the struts.multipart.maxSize family of the
file upload limits must agree with the proxy’s body size
limit), timeouts and buffering of long-running actions.
Source: Tomcat 11 — Remote IP Valve
Health Checks and Startup Failure
Container platforms ask the application whether it is alive and ready. The cheapest probe is a servlet that answers a constant. It must bypass Struts (the previous section on Keeping Requests Away from Struts), and it must not touch the database, or the whole fleet is restarted when the database is slow.
package org.example.bookshelf;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/health")
public class HealthServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("text/plain");
resp.getWriter().print("UP");
}
}
A servlet that always answers 200 is useful for exactly one reason that the bogus-cache run proved: when the Struts filter
fails to initialise (a bad constant, a malformed struts.xml, a missing class), Tomcat does not start the context at all,
and the health servlet, the static files and every action answer 404 together.
$ curl -s -o /dev/null -w "books %{http_code}\n" http://localhost:18081/g12deploy/books
books 404
$ curl -s -o /dev/null -w "health %{http_code}\n" http://localhost:18081/g12deploy/health
health 404
$ grep -a SEVERE base/logs/catalina.out | tail -2
SEVERE ... StandardContext.startInternal One or more Filters failed to start. Full details will be found in the appropriate container log file
SEVERE ... StandardContext.startInternal Context [/g12deploy] startup failed due to previous errors
A readiness probe on /health therefore catches a configuration error (the pod never becomes ready and the rollout stops)
without the application having to say so. The error itself, with the Caused by chain of the Struts exception, is in the
container log; the first line to read after a failed deployment is the SEVERE message above, and the cause is the last
Caused by line.
Docker
The Docker pages of this site cover images, layers, caches, non-root users and scanning; this page shows only what is
specific to a Struts WAR. The Getting Started page of the JSP part has the verified multi-stage Dockerfile for a WAR on
the official tomcat image (the image’s webapps directory is empty, a WAR named ROOT.war is served at /, and the
image runs Tomcat as root): A Docker Image for the WAR. For the
production WAR the Struts-specific parts are the build, the memory flags and the way to turn the constants into environment
choices. The Docker daemon was not available for this page, so the following file was not built; it differs from the
verified one only in the final stage.
# 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/g12prod.war /usr/local/tomcat/webapps/ROOT.war
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
EXPOSE 8080
Source: Docker Official Image — tomcat
Four rules carry over from the earlier sections. Use JAVA_OPTS (the JVM flags verified above were read back with jcmd);
if the image ships a setenv.sh that assigns CATALINA_OPTS, an environment variable of the same name is overwritten
(observed in this check with the scratch Tomcat, whose setenv.sh replaced the options the launcher exported). Struts
constants are not read from system properties (verified) and no mechanism for environment variables is documented, so
per-environment changes are either build
variants (Production Constants and Environment-Specific Configuration), files mounted into the image (a struts.properties or an extra struts-*.xml read through the
config init-param) or the container platform’s mechanism that the proxy and the application share. Send the logs to
standard output (the log4j2.xml of Logging), so that docker logs shows them. And size the heap relative to the
container memory with MaxRAMPercentage, because an -Xmx copied from a bare-metal server is wrong in a container with a
512 Mi limit.
The Docker pages cover the rest: Dockerfile Fundamentals, Java and Spring Boot Images, Image Best Practices and Security and Resources, Logging and Monitoring. For integration tests that start the WAR in a container, Testcontainers Fundamentals applies unchanged.
Kubernetes
The Kubernetes pages of this site explain every object; the manifest below only shows what a Struts WAR adds to a standard Deployment: probes on the health servlet, JVM flags from the container memory and a memory limit that the heap setting can use. It was parsed as YAML but not applied to a cluster, because no cluster was available.
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookshelf-struts
spec:
replicas: 2
selector:
matchLabels:
app: bookshelf-struts
template:
metadata:
labels:
app: bookshelf-struts
spec:
terminationGracePeriodSeconds: 30
containers:
- name: tomcat
image: registry.example.test/bookshelf-struts:1.0.0
ports:
- containerPort: 8080
env:
- name: JAVA_OPTS
value: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 512Mi
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 5
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: bookshelf-struts
spec:
selector:
app: bookshelf-struts
ports:
- port: 80
targetPort: 8080
The probe path /health answers 200 only when the Struts filter started, as shown above, and the readiness probe’s
five-second period lets a bad rollout stop early. The image name is a placeholder; nothing in it is a credential. A
non-root user and a read-only root file system are good practice but need the temp, work and logs directories of Tomcat on
writable volumes, and were not tried; start from
Health Probes and Lifecycle,
Resource Management,
Deployment Strategies and
ConfigMaps for each. A rolling update brings old and new versions up together,
so session handling matters: sessions live in the pod that created them and are lost when it is replaced, unless the
application keeps nothing important in the session or the container replicates sessions. The production Bookshelf keeps nothing in the session.
Official Examples, Showcase Applications and Archetypes
The Struts project maintains three sources of working code, and each has a catch that this page checked.
| Resource | What it is | State |
|---|---|---|
A Maven multi-module project of 47 modules, one per Getting Started tutorial ( |
The parent |
|
|
A large demo of the tags, themes and plugins and a REST application, tagged with each release |
At the |
Maven archetypes ( |
Generators of a skeleton application |
The latest published version of every one is |
Source: Struts Examples — README
Source: Struts 7.4.0 — apps/README.md
Source: Struts — Maven Archetypes
The examples are the right place to see a feature in the smallest possible application; every topic page of this section
names the module that matches it. Because the examples lag the release (7.3.0 against 7.4.0), read them for structure and
take versions from the BOM of your own project. The showcase is the right place to see every tag and every theme in one
application, and the better thing to run when comparing a tag’s behaviour with the Tag Developer Guide. The archetypes are
the weak spot: they generate Struts 6.0.0 projects, an xmlns.jcp.org descriptor and an old DTD. A Struts 7 project is better
started from the BOM and the filter as in Getting Started with Struts, with the
struts-6.0.dtd DOCTYPE that every example of this section uses (why 6.0).
A Production Checklist
The checklist collects the page into a table to read before a deployment. Rows that point to other pages are the ones that need a decision rather than a constant.
| Check | What to do | Where |
|---|---|---|
Development mode and reloading are off |
|
|
The Struts security settings are on |
|
the security hardening page of this section |
Static content has an owner |
|
|
Servlets and health endpoints bypass Struts |
|
|
Caches and logging |
OGNL cache type and size, FreeMarker |
|
No unnecessary sessions |
|
|
Errors do not leak |
Struts exception mappings, |
|
The proxy and the application agree |
|
|
The container is minimal |
No default webapps, current Tomcat 11.0.x, JSP |
|
Health and startup failure are visible |
A servlet at |
|
Memory and supervision |
|
|
Dependencies are current |
The |
the security bulletins page of this section |
Worked Example: Smoke-Testing the Bookshelf Production WAR
The checklist was applied to the g12prod WAR and a single script of curl calls was run against it on the private Tomcat
instance (context path /g12prod). The session id of the one cookie shown is replaced by a placeholder.
== books
HTTP/1.1 200
Vary: sec-fetch-dest,sec-fetch-mode,sec-fetch-site,sec-fetch-user,accept-encoding
Cross-Origin-Embedder-Policy-Report-Only: require-corp
Cross-Origin-Opener-Policy: same-origin
Content-Type: text/html;charset=UTF-8
== health
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
== assets/app.css
HTTP/1.1 200
Cache-Control: max-age=604800
Expires: Sat, 17 Oct 2026 11:26:41 GMT
Content-Type: text/css
== static/utils.js
HTTP/1.1 200
Cache-Control: max-age=604800
Expires: Sat, 17 Oct 2026 11:26:41 GMT
Content-Type: text/javascript;charset=ISO-8859-1
== nosuch
HTTP/1.1 404
Content-Type: text/html;charset=ISO-8859-1
== boom
HTTP/1.1 500
Content-Type: text/html;charset=ISO-8859-1
== books?request_locale=es
HTTP/1.1 200
Set-Cookie: JSESSIONID=<id>; Path=/g12prod; Secure; HttpOnly; SameSite=Lax
== books?debug=xml
HTTP/1.1 200
Content-Type: text/html;charset=UTF-8
$ curl -s http://localhost:18081/g12prod/health
UP
$ curl -s http://localhost:18081/g12prod/nosuch
<!DOCTYPE html>
<html><body><h1>Not found</h1><p>The page you asked for does not exist.</p></body></html>
$ curl -s http://localhost:18081/g12prod/boom
<!DOCTYPE html>
<html><body><h1>Something went wrong</h1><p>The error has been logged.</p></body></html>
$ grep -a "catalogue unavailable" base/logs/catalina.out | head -1
2026-10-10T13:26:41,785 ERROR DefaultDispatcherErrorHandler - Exception occurred during processing request: catalogue unavailable
Source: Tomcat 11 — Error Report Valve
Source: Tomcat 11 — Expires Filter
Read the transcript as the evidence for each row of the checklist. The books response carries the Struts security headers of
the default stack and has no Set-Cookie. The static files carry the seven-day lifetime. health and assets/app.css
bypass the Struts filter. A request for a missing page and for a failing action produce the application’s own HTML, with no
server version, no stack trace and no Struts message, and the stack trace is in the log. A request with request_locale
creates the only session, with all three cookie flags. And ?debug=xml is an ordinary page. The same WAR answered 200 on
Jetty 12.1.14 and GlassFish 8.0.5 apart from the Tomcat-only ExpiresFilter, which Jetty and GlassFish would need
equivalents for.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
A servlet at |
The filter on |
Add the path to |
Every URL of the context answers 404 after a configuration change (observed) |
The Struts filter failed to initialise, so Tomcat did not start the context |
Read the |
A cache constant from the documentation seems to have no effect (observed) |
|
Use |
The application will not start after setting the cache type (observed) |
An unknown type value fails the filter with a |
Use |
A template edit takes five seconds to show, or never shows (observed) |
FreeMarker’s update delay is 5 s by default; with |
Use a short delay in development and a long one in production |
Readable HTML expected under |
Whitespace stripping is not disabled by |
Set |
|
|
Extract them (Static Content) or turn the constant back on |
WebJar URLs return 404 (observed) |
WebJars ride the static pipeline, which |
Serve the jar resources another way, or enable the pipeline and check the allowlist |
A |
Struts constants are not read from system properties; the option works only when referenced as |
Reference it in the constant ( |
JVM options set outside are missing in the process (observed) |
A |
Use |
A leaner interceptor stack loses the Cross-Origin headers (observed) |
|
Start from a copy of |
A session cookie appears on a page that "uses no session" (observed) |
|
Remove the parameter use, |
Links point to |
The container sees plain HTTP |
|
The 404 page still shows |
The default |
|
After moving to |
Hard-coded context path in the application |
Build URLs with |
A long |
Same URL, new content |
Fingerprint the file name or use a short lifetime |
The examples fail to run on a current JDK or container |
The |
Take versions from the |
The archetype generates a Struts 6.0.0 project |
The archetypes were last published in 2023 |
Start from the BOM, as in the Getting Started page |
What Changed Recently
| Version | Change |
|---|---|
6.0.0 |
The OGNL expression and BeanInfo caches became configurable; custom cache factories can be installed |
6.1.0 |
|
6.4.0 |
Cache types ( |
7.0.0 |
Jakarta namespace and Servlet 6; every |
7.2.0 |
FreeMarker whitespace stripping and the |
7.3.0 |
WebJars support ( |
7.4.0 |
Released 2026-10-02; the examples repository still uses 7.3.0; no 7.x Maven archetype exists |
The behaviour changes that matter to a production move from Struts 6 to 7 (Jakarta namespace, struts.mapper.alwaysSelectFullNamespace
true, the allowlist and @StrutsParameter requirements) are in the section’s migration page; the Tomcat side is that a
Tomcat 11 webapps directory accepts only Jakarta WARs and answers 404 for a javax one without a message (see the
pitfalls of the Getting Started page).
Where the Books Are Outdated
The books on Struts and on web deployment were written for Struts 2.x on Tomcat 5 to 8, and four assumptions are out of date.
| Book-era practice | Today |
|---|---|
Tune with |
Constants in |
Rely on |
A bounded |
Serve |
|
Deploy to Tomcat 5, WebLogic, WebSphere or JBoss 5 from a |
A WAR in |
Configure one |
Build variants or a managed configuration; constants are not read from system properties |
Scaling is a matter of more nodes |
Cloud-era deployment is containers, probes, supervised processes and a proxy that terminates TLS; the application stays stateless |
Struts 2 archetypes as the project starting point |
The archetypes stop at 6.0.0; start from the BOM |
Inconsistencies in the Official Documentation
Several statements of the official pages do not match 7.4.0. The first three are measured on the Bookshelf, the rest were read from the pages and the jar.
-
The Performance Tuning page gives the FreeMarker
template_update_delaydefault as 500 ms; the measured default is 5 s, and the unit of the Struts constant is seconds. -
The page and
default.propertiessay whitespace stripping is disabled indevMode;FreemarkerManagerhas no such check and adevModerun was still stripped. -
The OGNL cache page documents
expressionCacheLRUModeandbeanInfoCacheLRUMode, which 7.4.0 ignores, and does not listexpressionCacheType,beanInfoCacheTypeor the localized-text and proxy caches. -
The Static Content page says Struts serves content "from the classpath" under
/static; the files in the jar are underorg/apache/struts2/staticandtemplate/<theme>/, so "extract to the web application directory" is not a plain unzip. -
DefaultStaticContentLoadersends aRetry-Afterheader with theExpiresdate on a 200 response. -
The WebJar section describes version-less URLs; the
webjartag writes the versioned URL, and the feature needsstruts.serve.static=true. -
The Performance Tuning page still names the Ajax theme (Dojo), the Calendar tag and
struts.freemarker.templatesCache; none exists in 7.4.0. -
Application Servers lists servers from the 2000s and Wildfly 10.x, links only some of them, and does not name Tomcat 10.1 or 11, Jetty 12 or GlassFish 8.
-
The
struts-examplesrepository is on 7.3.0 and Jetty 11 although 7.4.0 was released on 2026-10-02. -
GlassFish 8.0.5 reports
X-Powered-By: JSP/3.0on a JSP response, although it implements Pages 4.0.
Summary
A Struts 7.4 WAR is production-ready by default and the work is in what surrounds it. Keep devMode and the reload switches
off and check with ?debug=xml; let the container or a proxy serve static files (struts.serve.static=false, the jar’s
files extracted, ExpiresFilter or proxy caching) and send servlets and health endpoints around Struts with
struts.action.excludePattern; leave the OGNL caches at wtlfu and give FreeMarker a long update delay; keep logging at
warn for Struts and FreeMarker; create sessions only on purpose and do not trade the security-header interceptors for
speed; harden Tomcat with a Host-level ErrorReportValve, error pages, RemoteIpValve and cookie flags; deploy the same
WAR to Tomcat 11, Jetty 12.1 or GlassFish 8; and expose a /health servlet for probes, remembering that a failed Struts
filter takes the whole context down with a 404. Docker and Kubernetes add a start-up command, probes and memory limits
around that WAR and are covered by their own pages. Take learning code from struts-examples and the showcase, versions
from the BOM, and never from a 6.0.0 archetype.
References
-
Core Developer Guide — Performance Tuning, Turn off logging and devMode
-
Core Developer Guide — Performance Tuning, Do not use interceptors you do not need
-
Core Developer Guide — Performance Tuning, freemarker.properties
-
Core Developer Guide — Performance Tuning, Whitespace Stripping
-
Core Developer Guide — Performance Tuning, Do not create sessions unless you need them
-
Core Developer Guide — Static Content, Disabling static content
-
Core Developer Guide — Static Content, Default Content Loader
-
Core Developer Guide — Static Content, Preventing Struts from handling a request
-
Struts Javadoc — StrutsConstants.STRUTS_OGNL_EXPRESSION_CACHE_TYPE
-
Struts Javadoc — StrutsConstants.STRUTS_ACTION_EXCLUDE_PATTERN
-
Tomcat 11 — Security Considerations, Default web applications
-
Eclipse GlassFish Quick Start Guide — Deploying and Undeploying Applications
-
Kubernetes — Configure Liveness, Readiness and Startup Probes