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.

Table 1. Settings that differ between development and production
Setting 7.4.0 default Production Why

struts.devMode

false

false

On, it reloads configuration and resource bundles, turns on the debugging interceptor (?debug=xml dumps the value stack) and makes problems louder; the Core Developer Guide says it "can expose sensitive data"

struts.configuration.xml.reload

false

false

Re-reads XML configuration when a file changes; "not recommended for production use"

struts.i18n.reload

false

false

Re-reads resource bundles on each request

struts.freemarker.templatesCache.updateDelay

not set (FreeMarker’s 5 s)

a large value such as 3600

How often a template file is checked for changes; templates do not change in production

struts.serve.static

true

false when the container or a proxy serves the files

Struts can serve its own files from the jar, but the container "is not optimized" for it

struts.serve.static.browserCache

true

true

Keeps the browser cache headers on files that Struts serves

struts.ognl.expressionCacheType and struts.ognl.beanInfoCacheType

wtlfu

wtlfu

The bounded cache of parsed OGNL expressions and of bean information

struts.ognl.expressionMaxLength

256

256

A bound on the length of an OGNL expression; a security setting, kept

struts.allowlist.enable and struts.parameters.requireAnnotations

true

true

Security settings, never switched off to fix a development problem

struts.enable.DynamicMethodInvocation

false

false

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.

flowchart LR C["Browser"] --> P["Reverse proxy or load balancer: TLS, compression, caching"] P -->|"X-Forwarded-Proto and X-Forwarded-For"| T["Tomcat connector: optional gzip"] subgraph app["Tomcat: the Bookshelf web application"] V["Host valves: RemoteIpValve, ErrorReportValve"] --> E["ExpiresFilter for /static and /assets"] E --> S["StrutsPrepareAndExecuteFilter"] S -->|"action found"| A["Action, then JSP"] S -->|"excludePattern or no action mapping"| D["Container default servlet and HealthServlet"] end T --> V A --> R["HTML response"] D --> R2["Static file, health answer or error 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.

Table 2. The files that matter for production
File Role

src/main/resources/struts.xml

The production constants and the books and boom actions

src/main/webapp/WEB-INF/web.xml

The Struts filter, an ExpiresFilter, the session cookie settings and the error pages

src/main/resources/log4j2.xml

Logging at warn for Struts and FreeMarker, written to standard output

src/main/java/org/example/bookshelf/HealthServlet.java

The /health endpoint used by probes

src/main/webapp/static/...

The files of the Struts jar, extracted so that the container serves them

src/main/webapp/assets/app.css

The application’s own stylesheet

src/main/webapp/WEB-INF/views/books.jsp

The catalogue page; session="false" keeps JSP from creating sessions

The struts.xml is the heart of the production configuration. Each constant is explained in the sections that follow.

src/main/resources/struts.xml
<?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.

src/main/webapp/WEB-INF/web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_1.xsd"
         version="6.1">

    <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>

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).

devMode on and off (same action, same request)
$ 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.

Table 3. Ways to give the same WAR different settings per environment
Mechanism Result (observed) Comment

A filter init-param named like the constant, for example struts.devMode

Works: ?debug=xml dumped the stack

Static: it lives in web.xml, so it is per build, not per environment

An environment variable referenced in struts.xml: <constant name="struts.devMode" value="${env.STRUTS_DEV_MODE:false}"/>

Works: EnvsValueSubstitutor returned false with the variable unset and true with STRUTS_DEV_MODE=true

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 struts.properties file at build time

Works: the dev profile produced a WAR with struts.devMode=true, the default one false

One WAR per environment, which defeats "build once, deploy everywhere"

-Dstruts.devMode=true on the JVM command line

Works only when the constant is referenced as ${...} in struts.xml, for example value="${struts.devMode:false}"; with a plain value in struts.xml (or none), ?debug=xml stayed a normal page (observed)

Struts constants are not read from system properties directly; EnvsValueSubstitutor resolves ${name} from system properties and ${env.NAME} from environment variables, only inside <constant> values

The Maven variant needs only a filtered resource and a profile.

A filtered struts.properties (src/main/resources/struts.properties) and the pom.xml parts that filter it
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.

A request that Struts serves itself (defaults, headers as observed)
$ 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).

struts.ui.staticContentPath
<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.

struts.serve.static
<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.

Extracting the static files of the Struts 7.4.0 jar into the web application (run once, commit the result or run in the build)
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.

Headers of the static files in the production WAR (observed)
$ 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.

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.

A servlet at an extensionless URL is swallowed by the Struts filter (observed)
$ 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 [&#47;] and action name [health] associated with context path [&#47;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.

struts.action.excludePattern
<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.

A WebJar stylesheet in the page
<link rel="stylesheet" href="<s:webjar path="bootstrap/css/bootstrap.min.css"/>">
The page and the files it links (observed)
$ 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).

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.

Table 4. The performance tuning tips, checked against 7.4.0
Tip Status in 7.4.0 Evidence or advice

Turn off logging and devMode

Valid

devMode off is the default; log levels are a log4j2.xml matter (Logging)

Use the Java templates

Valid, as a plugin

struts2-javatemplates-plugin (published for 7.4.0) renders the simple theme without FreeMarker; see Other Plugins

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 (Cache-Control, Expires)

Valid

Static Content and HTTP Caching of Dynamic Pages

Copy static content when using the Ajax theme (Dojo) or the Calendar tag

Obsolete

7.4.0 ships the simple, xhtml and css_xhtml themes and has no Ajax theme and no calendar tag; the advice applies to s:head content in general (Static Content)

Create a freemarker.properties file with template_update_delay

Replaced by a constant

Use struts.freemarker.templatesCache.updateDelay; the default is 5 s, not 500 ms (The FreeMarker Template Cache)

Enable FreeMarker template caching with struts.freemarker.templatesCache

Removed

Only a commented line in default.properties; no effect (The FreeMarker Template Cache)

FreeMarker whitespace stripping (since 7.2.0)

Valid, on by default

Smaller HTML: Whitespace Stripping, s:compress and gzip

s:compress tag (since 7.2.0)

Valid, on by default

Smaller HTML: Whitespace Stripping, s:compress and gzip

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

Sessions, I18nInterceptor and the Interceptor Stack

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.

Table 5. OGNL cache constants in 7.4.0 (read from default.properties and StrutsConstants)
Constant Default Meaning

struts.ognl.enableExpressionCache

true

Toggle for the parsed-expression cache (since 6.0.0); disabling saves memory and costs speed

struts.ognl.expressionCacheType

wtlfu

basic (flushes everything when full), lru (drops the least recently used entry) or wtlfu (the W-TinyLFU policy of the bundled Caffeine library)

struts.ognl.expressionCacheMaxSize

10000

Maximum entries of the expression cache

struts.ognl.beanInfoCacheType and struts.ognl.beanInfoCacheMaxSize

wtlfu, 10000

The same for BeanInfo objects

struts.i18n.cacheType and struts.i18n.cacheMaxSize

wtlfu, 10000

The localized-text caches

struts.proxy.cacheType and struts.proxy.cacheMaxSize

wtlfu, 10000

The proxy-detection caches

struts.ognl.expressionCacheFactory and struts.ognl.beanInfoCacheFactory

not set

A custom OgnlCacheFactory

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.

Valid and invalid cache types on the Bookshelf (observed)
<!-- 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.

Table 6. How long a template edit stays invisible (observed, three runs each)
struts.freemarker.templatesCache.updateDelay Edit visible after

not set

5.1 s every time

1

1.1 s

3600

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.

struts.freemarker.templatesCache.updateDelay
<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.

Table 7. The Bookshelf form page with each mechanism (bytes of the response body, observed)
Configuration Bytes

struts.freemarker.whitespaceStripping=false

825

default (stripping on)

638

stripping on, s:compress around the list, struts.tag.compress.enabled=false

680

stripping on, s:compress around the list, enabled

660

the final production page, plain

816

the final production page, Accept-Encoding: gzip through the Tomcat connector

435

s:compress on the title list (default configuration)
<s:compress>
<ul>
  <s:iterator value="titles">
    <li><s:property/></li>
  </s:iterator>
</ul>
</s:compress>
The output without and with s:compress (observed)
<!-- 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.

A compressing connector (Tomcat 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.

Which requests create a session (observed; the session id is replaced by a placeholder)
$ 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.

Using validationWorkflowStack for one action
<action name="books" class="org.example.bookshelf.BookAction">
    <interceptor-ref name="validationWorkflowStack"/>
    <result>/WEB-INF/views/books.jsp</result>
</action>
Same request, default stack against validationWorkflowStack (observed)
$ 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.

src/main/resources/log4j2.xml
<?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>

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.

Table 8. The Bookshelf WAR on three containers (observed)
Container How it was run Result

Apache Tomcat 11.0.26

webapps copy (and, for the production checks, a private instance)

200; every check in this page

Eclipse Jetty 12.1.14

mvn jetty:run with jetty-ee11-maven-plugin 12.1.14, no extra dependency

200; Server: Jetty(12.1.14); same Struts headers as on Tomcat

Eclipse GlassFish 8.0.5 (web profile)

asadmin deploy --contextroot g12deploygf

200; the JSP engine reports itself as X-Powered-By: JSP/3.0; the Struts headers are the same

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.

The Jetty 12.1 Maven plugin on the Struts WAR (port 18082 here)
<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>
Deploying the WAR to GlassFish 8 and calling it
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.

Table 9. Tomcat production settings, each verified on 11.0.26
Setting Effect (observed) Where

Remove the default web applications (ROOT, docs, examples, manager, host-manager)

The Tomcat Security How-To names them as an information and risk surface; the official Docker image ships an empty webapps (see the Getting Started page)

webapps directory

ErrorReportValve with showReport="false" and showServerInfo="false"

A 404 body shrank from a report with the Struts message and the version Apache Tomcat/11.0.26 to <h1>HTTP Status 404 – Not Found</h1> alone; the web.xml error page replaces it entirely

A Valve of the Host element in server.xml

<error-page> entries for 404 and 500

The 404 and 500 answered the HTML files of the WAR, and the 500 from the failing action was logged at ERROR

web.xml

RemoteIpValve

Restores scheme, host and client address from X-Forwarded-* headers (Behind a Reverse Proxy)

A Valve of the Host element

Session cookie Secure, HttpOnly, SameSite

Set-Cookie: JSESSIONID=<id>; Path=/g12prod; Secure; HttpOnly; SameSite=Lax

<session-config> in web.xml

Compressing connector

Content-Encoding gzip, 816 to 435 bytes

Connector in server.xml (Smaller HTML: Whitespace Stripping, s:compress and gzip)

JVM flags -XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError

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 jcmd VM.flags)

JAVA_OPTS or CATALINA_OPTS

The two valves are plain server.xml elements inside the Host.

Host valves of the verified server.xml
<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" />

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.

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.

The three naming forms (observed)
$ 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.

An absolute URL without and with the proxy headers (observed, a host name invented for the test)
$ 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.

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.

src/main/java/org/example/bookshelf/HealthServlet.java
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.

A failed Struts filter takes the whole context down (observed)
$ 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.

Dockerfile (not built for this page)
# 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

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.

A Deployment and Service for the Bookshelf image (parsed only, not applied)
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.

Table 10. Learning resources and their state on 2026-10-10
Resource What it is State

apache/struts-examples

A Maven multi-module project of 47 modules, one per Getting Started tutorial (helloworld, form-processing, form-validation, crud, interceptors, tiles, themes, json, file-upload, spring-struts, unit-testing and more)

The parent pom.xml on main still sets struts2.version to 7.3.0 and jetty-maven-plugin to 11.0.18, compiles for Java 17; the README says to run mvn -e clean package and copy the WARs to a servlet container

apps/showcase and apps/rest-showcase in the Struts repository

A large demo of the tags, themes and plugins and a REST application, tagged with each release

At the STRUTS_7_4_0 tag; the README starts them with mvn jetty:run and port 8080

Maven archetypes (struts2-archetype-convention, -blank, -starter, -angularjs, -plugin, -portlet, -dbportlet)

Generators of a skeleton application

The latest published version of every one is 6.0.0, uploaded on 2023-03-15; there is no 7.x archetype

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.

Table 11. Production checklist for a Struts 7.4 WAR
Check What to do Where

Development mode and reloading are off

struts.devMode, struts.configuration.xml.reload, struts.i18n.reload false; ?debug=xml answers a normal page

Production Constants and Environment-Specific Configuration

The Struts security settings are on

struts.allowlist.enable, struts.parameters.requireAnnotations, struts.enable.DynamicMethodInvocation=false, the expression length limit

the security hardening page of this section

Static content has an owner

struts.serve.static=false with the files extracted and cached, or a proxy or CDN

Static Content

Servlets and health endpoints bypass Struts

struts.action.excludePattern

Keeping Requests Away from Struts

Caches and logging

OGNL cache type and size, FreeMarker updateDelay, warn for Struts and FreeMarker

Performance Tuning

No unnecessary sessions

session="false" on JSPs, a stack without i18n only if the headers stay

Sessions, I18nInterceptor and the Interceptor Stack

Errors do not leak

Struts exception mappings, web.xml error pages, ErrorReportValve with showReport and showServerInfo false

Tomcat in Production

The proxy and the application agree

RemoteIpValve, the cookie Secure and SameSite flags, upload limits, timeouts

Behind a Reverse Proxy

The container is minimal

No default webapps, current Tomcat 11.0.x, JSP development=false

Tomcat in Production, JSP Compilation in Production

Health and startup failure are visible

A servlet at /health; read the SEVERE line after a deployment

Health Checks and Startup Failure

Memory and supervision

MaxRAMPercentage, ExitOnOutOfMemoryError and something that restarts the process

Tomcat in Production, Kubernetes

Dependencies are current

The struts2-bom of the latest 7.x, and the bulletins read

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.

Headers of the production WAR’s responses (selected headers, observed)
== 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
Bodies of the same responses
$ 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

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

Table 12. Deployment and production pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Tomcat 11.0.26 with Struts 7.4.0)
Symptom Cause Fix

A servlet at /health answers 404 with "There is no Action mapped" (observed)

The filter on /* treats an extensionless URL as an action name because struts.action.extension is action,,

Add the path to struts.action.excludePattern

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 SEVERE and last Caused by lines in the container log

A cache constant from the documentation seems to have no effect (observed)

struts.ognl.expressionCacheLRUMode and beanInfoCacheLRUMode do not exist in 7.4.0 and are ignored

Use struts.ognl.expressionCacheType and struts.ognl.beanInfoCacheType

The application will not start after setting the cache type (observed)

An unknown type value fails the filter with a NullPointerException

Use basic, lru or wtlfu

A template edit takes five seconds to show, or never shows (observed)

FreeMarker’s update delay is 5 s by default; with 3600 it is an hour

Use a short delay in development and a long one in production

Readable HTML expected under devMode is still compact (observed)

Whitespace stripping is not disabled by devMode in 7.4.0 despite the documentation

Set struts.freemarker.whitespaceStripping to false in development

/static/utils.js returns 404 and forms lose their style (observed)

struts.serve.static is false and the files were not extracted

Extract them (Static Content) or turn the constant back on

WebJar URLs return 404 (observed)

WebJars ride the static pipeline, which struts.serve.static=false switched off, or the name is not in struts.webjars.allowlist

Serve the jar resources another way, or enable the pipeline and check the allowlist

A -Dstruts.devMode=true option is ignored (observed)

Struts constants are not read from system properties; the option works only when referenced as ${...} in struts.xml

Reference it in the constant (value="${struts.devMode:false}"), or use ${env.NAME:default} with an environment variable (Configuration: struts.xml and Constants)

JVM options set outside are missing in the process (observed)

A setenv.sh that assigns CATALINA_OPTS replaces the exported value

Use JAVA_OPTS or append in setenv.sh

A leaner interceptor stack loses the Cross-Origin headers (observed)

basicStack and validationWorkflowStack have no coop, coep, fetchMetadata, csp

Start from a copy of defaultStack and remove entries by name

A session cookie appears on a page that "uses no session" (observed)

request_locale makes the i18n interceptor store the locale in the session, and a JSP creates a session unless session="false"

Remove the parameter use, session="false", or accept the session

Links point to http:// behind TLS termination (observed)

The container sees plain HTTP

RemoteIpValve with the forwarded headers, trusted proxies only

The 404 page still shows Apache Tomcat/11.0.26

The default ErrorReportValve writes the version

showServerInfo="false" and showReport="false" on a Host valve, plus <error-page> entries

After moving to ROOT.war links break

Hard-coded context path in the application

Build URLs with s:url and s:form, never with a literal prefix

A long max-age serves an old app.css after a release

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 struts-examples parent is on 7.3.0 and Jetty 11

Take versions from the struts2-bom and run on Tomcat 11 or Jetty 12.1

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

Table 13. Changes that touch deployment and production
Version Change

6.0.0

The OGNL expression and BeanInfo caches became configurable; custom cache factories can be installed

6.1.0

struts.action.excludePattern.separator allows a separator other than the comma

6.4.0

Cache types (basic, lru, wtlfu) replace the on/off and LRUMode switches; the Caffeine library is a dependency

7.0.0

Jakarta namespace and Servlet 6; every javax.servlet WAR and web.xml must be migrated; Java 17 or newer

7.2.0

FreeMarker whitespace stripping and the s:compress tag

7.3.0

WebJars support (struts.webjars.*, the webjar tag)

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.

Table 14. Book-era practice against today (Struts 7.4, Tomcat 11)
Book-era practice Today

Tune with struts.properties, a freemarker.properties file and struts.freemarker.templatesCache

Constants in struts.xml (or struts.properties); struts.freemarker.templatesCache.updateDelay is the only template-cache knob that exists

Rely on struts.ognl.enableExpressionCache and an OGNL jar swap for speed

A bounded wtlfu cache by default; no jar swap, OGNL is a managed dependency

Serve /struts/ or /static/ content from the Struts jar and copy template/ for FreeMarker caching

/static by constant; copy the static files, not the templates; templates found in the web application are cached by update delay

Deploy to Tomcat 5, WebLogic, WebSphere or JBoss 5 from a server.xml context entry

A WAR in webapps or ROOT.war, with a Jakarta Servlet 6 container; the products on the Application Servers page are not targets

Configure one struts.xml per environment by hand-editing it on the server

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_delay default as 500 ms; the measured default is 5 s, and the unit of the Struts constant is seconds.

  • The page and default.properties say whitespace stripping is disabled in devMode; FreemarkerManager has no such check and a devMode run was still stripped.

  • The OGNL cache page documents expressionCacheLRUMode and beanInfoCacheLRUMode, which 7.4.0 ignores, and does not list expressionCacheType, beanInfoCacheType or 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 under org/apache/struts2/static and template/<theme>/, so "extract to the web application directory" is not a plain unzip.

  • DefaultStaticContentLoader sends a Retry-After header with the Expires date on a 200 response.

  • The WebJar section describes version-less URLs; the webjar tag writes the versioned URL, and the feature needs struts.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-examples repository 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.0 on 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