Put Tomcat behind Nginx with the usual proxy settings, and the Manager app that ships locked to localhost will answer anyone who can reach Nginx. Every proxied request arrives at Tomcat from 127.0.0.1, so the localhost-only rule passes it, and only the Manager password stands in the way. We reproduced that on Rocky Linux 10. A remote browser got the Manager page through Nginx until RemoteIpValve went in, and even then a forged X-Forwarded-For header got past it until Nginx stopped passing the client’s own header along.
This guide installs Tomcat from the distro packages on Rocky Linux 10 and Fedora, enables the Manager safely, deploys a WAR, and puts Nginx with HTTPS in front with the SELinux boolean and the proxy settings Tomcat needs. It also installs the upstream Tomcat 11 tarball with a systemd unit that survives a JDK change. The packaged install, Manager and WAR steps ran on Rocky Linux 10.2 and Fedora 45, the Nginx and HTTPS steps on Rocky, and the tarball on Fedora 44, all in October 2026. AlmaLinux 10 and RHEL 10 ship the same packages as Rocky, but we only ran Rocky.
Choose the packaged Tomcat or the tarball
Take the distro package unless you need Tomcat 11. Both distros ship the 10.1 branch (Jakarta EE 10, Servlet 6.0), patched through dnf upgrade and wired into systemd and SELinux by the packager. The upstream tarball is the only way to Tomcat 11 (Jakarta EE 11) on either distro, and you take over updates, the service unit and the Java path yourself. Here is what each option gave us.
| Rocky Linux 10 package | Fedora 45 package | apache.org tarball | |
|---|---|---|---|
| Version tested | 10.1.49 (Tomcat 9.0.120 also packaged as tomcat9) | 10.1.59 | 11.0.27 |
| Java it ran on | JDK 21 via /usr/lib/jvm/jre | JDK 25 via /usr/bin/java | Whatever JAVA_HOME in your unit says; Java 17 or newer |
| Files | /etc/tomcat, /var/lib/tomcat/webapps, /var/log/tomcat | Same paths, CATALINA_BASE=/var/lib/tomcat | Everything under /opt/tomcat |
| Shutdown port 8005 | Listening on localhost | Disabled (port="-1") | Listening on localhost |
| Service sandbox | None | ProtectSystem=strict with a list of writable paths | None unless you add it |
Fedora’s package looks the same from the outside but is built differently. Its unit runs Tomcat through /usr/libexec/tomcat/tomcat-run.sh with ProtectSystem=strict, which makes the filesystem read-only for the service except the paths the unit lists: /etc/tomcat/Catalina/, the two webapps directories, /var/log/tomcat, its cache directory and /tmp. An app that writes uploads to, say, /srv would need a ReadWritePaths= drop-in on Fedora on top of the usual directory ownership. We didn’t test that case.
Install Tomcat from the repositories
The same three packages work on both distros. tomcat-webapps adds the default ROOT page and tomcat-admin-webapps adds the Manager and Host Manager apps. Skip the latter on a server where nobody will deploy through a browser. Java comes in as a dependency if you have none yet.
sudo dnf install -y tomcat tomcat-webapps tomcat-admin-webapps
sudo systemctl enable --now tomcat
Check the service on Fedora 45. The Main PID is a shell because of the wrapper script, and the drop-in line is a Fedora-wide setting (TimeoutStopFailureMode=abort, what systemd does if a stop times out), not something Tomcat added.
systemctl status tomcat --no-pager
You want to see active (running).
● tomcat.service - Apache Tomcat Web Application Container
Loaded: loaded (/usr/lib/systemd/system/tomcat.service; enabled; preset: disabled)
Drop-In: /usr/lib/systemd/system/service.d
└─10-timeout-abort.conf
Active: active (running) since Thu 2026-10-15 18:49:04 UTC; 323ms ago
Invocation: 56a126e9c067433dbeebf6316e5bb7bb
Docs: https://tomcat.apache.org/tomcat-10.1-doc/index.html
Main PID: 15240 (sh)
Tomcat takes a few seconds to deploy its apps after systemd reports it running, so give it a moment before you test the connector on port 8080:
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/
A 200 means the default ROOT app answered on both of our machines. A connection refused right after a start usually means you were faster than the deployment. Check journalctl -u tomcat if it persists. In a browser the same port shows the default page. This is Rocky’s packaged 10.1:

The Java version follows the distro. Rocky’s /etc/tomcat/tomcat.conf sets JAVA_HOME="/usr/lib/jvm/jre", which pointed at JDK 21, and installing java-25-openjdk-headless later did not move it, so Tomcat stayed on 21 after a restart. Fedora 45 has only JDK 25 in its repositories and Tomcat runs on that; our Java on Fedora guide covers adding JDK 21 when an app is not ready for 25. Change the JDK on Rocky by setting JAVA_HOME in /etc/tomcat/tomcat.conf rather than by installing another JDK and hoping. Our JDK 25 guide for Rocky Linux 10 covers installing the newer JDK first.
Enable the Manager app
The Manager deploys, reloads and undeploys apps from a browser, and it ships with no users at all. Every role in /etc/tomcat/tomcat-users.xml is commented out, so the first step is a user with the manager-gui role:
sudo vim /etc/tomcat/tomcat-users.xml
Add these two lines just above the closing </tomcat-users> tag. Use a long random password, because this account can deploy code onto the server:
<role rolename="manager-gui"/>
<user username="tcadmin" password="use-a-long-random-password" roles="manager-gui"/>
After sudo systemctl restart tomcat, the Manager answered 200 with those credentials from the server itself on both distros, and 403 from any other machine. That second number comes from a valve in the Manager’s own META-INF/context.xml, which both distros ship as RemoteCIDRValve with allow="127.0.0.0/8,::1/128". Older guides, including the previous version of this one, tell you to edit a RemoteAddrValve regex in that file, but that valve is not there any more.
Do not edit the packaged file either. rpm -qc tomcat-admin-webapps does not list it as a config file, so the next package update can replace your change without warning. Tomcat reads a context file for the Manager from /etc/tomcat/Catalina/localhost/manager.xml in preference to the one inside the app, so copy the packaged one there and widen it to your admin network:
sudo cp /var/lib/tomcat/webapps/manager/META-INF/context.xml /etc/tomcat/Catalina/localhost/manager.xml
sudo sed -i "s|allow=\"127.0.0.0/8,::1/128\"|allow=\"127.0.0.0/8,::1/128,10.0.1.0/24\"|" /etc/tomcat/Catalina/localhost/manager.xml
sudo chown root:tomcat /etc/tomcat/Catalina/localhost/manager.xml
sudo grep -A1 RemoteCIDRValve /etc/tomcat/Catalina/localhost/manager.xml
You should see the extra subnet in the valve.
<Valve className="org.apache.catalina.valves.RemoteCIDRValve"
allow="127.0.0.0/8,::1/128,10.0.1.0/24" />
Restart Tomcat once more. Our cloud images had no firewalld, so port 8080 was reachable for this test. On a host with firewalld, run the remote check after the Nginx section instead. From a workstation in that subnet the Manager returned 200 with the password and 401 without it on both Rocky and Fedora 45, which is the pair you want: the network check passes and the login still applies. Keep the subnet as small as your admin network. The default LockOutRealm in server.xml slows down password guessing, but an account that can deploy code deserves a network limit as well. This is the Manager on Rocky after the sample app below was deployed:

The /scheme entry is a throwaway test app from the proxy section further down. Your list will show ROOT, the two manager apps and whatever you deploy.
Deploy a WAR file
Tomcat auto-deploys any WAR dropped into /var/lib/tomcat/webapps, so deploying is a copy with the right owner. The Apache sample app is small and exercises both a servlet and a JSP, which proves the JSP compiler works too:
curl -sSL -o /tmp/sample.war https://tomcat.apache.org/tomcat-10.1-doc/appdev/sample/sample.war
sudo install -o tomcat -g tomcat -m 644 /tmp/sample.war /var/lib/tomcat/webapps/sample.war
Within about ten seconds Tomcat unpacks it next to the WAR. Request the JSP page and you should get its heading back.
curl -s http://localhost:8080/sample/hello.jsp | grep -iE "<title>|<h1>|hello" | head -3
Both distros served the compiled page, and /sample/hello, the servlet, returned 200:
<title>Sample Application JSP Page</title>
<h1>Sample Application JSP Page</h1>
This is the output of a JSP page that is part of the Hello, World
To remove an app, delete the WAR and Tomcat undeploys the directory with it. If you build your own WAR with Maven, our Maven guide for Fedora and Rocky Linux covers the build side, and mvn package leaves the WAR in target/ ready to copy.
Put Nginx and HTTPS in front of Tomcat
Nginx terminates TLS and Tomcat stays on localhost, which is the setup we’d run in production. Getting it right takes five pieces, and the last two are the ones most guides miss or get subtly wrong:
- an Nginx server block that proxies to 127.0.0.1:8080 over HTTP/1.1 and overwrites
X-Forwarded-For - an SELinux boolean so Nginx may connect to port 8080
- HTTP and HTTPS open in the firewall, 8080 closed
- the Tomcat connector bound to 127.0.0.1
RemoteIpValve, trusting forwarded headers from 127.0.0.1 only
Install Nginx and enable it.
sudo dnf install -y nginx
sudo systemctl enable --now nginx
Nginx refuses to load an HTTPS server block whose certificate files don’t exist, and certbot’s Nginx plugin runs nginx -t before it does anything, so put a certificate in place first. Our lab had no public DNS name, so we generated a self-signed one at the paths the server block below uses. On a server with a real domain, keep this step anyway; certbot replaces the two certificate lines in the next section:
sudo openssl req -x509 -nodes -newkey rsa:2048 -days 30 -subj "/CN=tomcat.example.com" -keyout /etc/pki/tls/private/tomcat.key -out /etc/pki/tls/certs/tomcat.crt
Now create the server block.
sudo vim /etc/nginx/conf.d/tomcat.conf
This is the final file from our Rocky box. Replace tomcat.example.com with your name. Three lines carry most of the weight. X-Forwarded-For $remote_addr overwrites whatever forwarding header the client sent, which matters for the Manager, as the valve section shows. proxy_http_version 1.1 fixes an HTTPS-to-HTTP redirect on Rocky, whose Nginx 1.26 still proxies with HTTP/1.0 by default; Fedora’s Nginx 1.30 already defaults to 1.1, so the line is harmless there. client_max_body_size lets you upload WARs larger than Nginx’s 1 MB default through the Manager:
server {
listen 80;
listen [::]:80;
server_name tomcat.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name tomcat.example.com;
ssl_certificate /etc/pki/tls/certs/tomcat.crt;
ssl_certificate_key /etc/pki/tls/private/tomcat.key;
client_max_body_size 100m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
}
}
Check the syntax with sudo nginx -t and reload with sudo systemctl reload nginx. On Rocky with SELinux enforcing, the first proxied request then fails with a 502. We first hit it with a plain HTTP version of this block, and the Nginx error log says exactly why:
2026/10/15 18:53:23 [crit] 67121#67121: *1 connect() to 127.0.0.1:8080 failed (13: Permission denied) while connecting to upstream, client: ::1, server: tomcat.example.com, request: "GET /sample/ HTTP/1.1", upstream: "http://127.0.0.1:8080/sample/", host: "tomcat.example.com"
Port 8080 carries the http_cache_port_t label, and Nginx’s SELinux domain may not connect to it by default. Most guides flip httpd_can_network_connect, which lets Nginx connect to any port at all. httpd_can_network_relay is aimed at reverse proxies: it allows the HTTP, HTTP-cache and a few other proxy-type ports plus the ephemeral port range, rather than every port, and it fixed the 502 on its own in our test, with httpd_can_network_connect left off:
sudo setsebool -P httpd_can_network_relay 1
Neither of our cloud images ran firewalld, but a full Rocky Linux or Fedora Server install does. Open HTTP and HTTPS only; port 8080 stays closed, and after the connector change below nothing outside the box could reach it anyway. We ran this on Rocky after installing firewalld:
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reload
Get a Let’s Encrypt certificate
With a public DNS name pointing at the server and port 80 reachable, certbot’s Nginx plugin gets the certificate and rewrites the two certificate lines. The packages come from different places on each distro. On Rocky Linux 10, certbot is in EPEL:
sudo dnf install -y epel-release
sudo dnf install -y certbot python3-certbot-nginx
On Fedora 45 and 44 it’s in the main repositories, but the Nginx plugin fails to load without python3-pyparsing, a missing dependency our Django on Fedora guide hit on both releases. Install it alongside:
sudo dnf install -y certbot python3-certbot-nginx python3-pyparsing
Then request the certificate for the name in server_name and start the renewal timer, which the package enables but does not start until the next boot:
sudo certbot --nginx -d tomcat.example.com
sudo systemctl enable --now certbot-renew.timer
Our lab had no public name, so we never ran the certbot --nginx line and kept the self-signed certificate, which exercises Tomcat exactly the same way. On Rocky 10 we did confirm that certbot plugins lists the Nginx plugin with no load errors, and that certbot-renew.timer was enabled but inactive right after the install.
Bind Tomcat to localhost and add RemoteIpValve
Behind the proxy, Tomcat still thinks every request is plain HTTP from 127.0.0.1. We dropped a one-line JSP into a throwaway app to show what Tomcat sees, then requested it through Nginx over HTTPS:
sudo mkdir -p /var/lib/tomcat/webapps/scheme
echo "scheme=<%= request.getScheme() %> secure=<%= request.isSecure() %> remote=<%= request.getRemoteAddr() %>" | sudo tee /var/lib/tomcat/webapps/scheme/index.jsp
sudo chown -R tomcat:tomcat /var/lib/tomcat/webapps/scheme
curl -sk --resolve tomcat.example.com:443:127.0.0.1 https://tomcat.example.com/scheme/
Over HTTPS, Tomcat reported the wrong scheme and the proxy’s address.
scheme=http secure=false remote=127.0.0.1
Apps that build absolute links or mark cookies secure from those values get both wrong. Worse, the Manager’s CIDR check reads the same remote address, so every visitor coming through Nginx counts as localhost. RemoteIpValve fixes the scheme and the address, but its defaults have a trap of their own: it trusts forwarded headers from every private and loopback range. We removed the subnet override from earlier, so the Manager was back on its packaged localhost-only rule, and sent the same authenticated request from a workstation, once plain and once with a forged X-Forwarded-For: 127.0.0.1 header:
| Nginx X-Forwarded-For | RemoteIpValve | Plain request | Forged 127.0.0.1 header |
|---|---|---|---|
$proxy_add_x_forwarded_for | absent | 200 | not tested (nothing reads the header) |
$proxy_add_x_forwarded_for | default internalProxies | 403 | 200 |
$remote_addr | default internalProxies | 403 | 403 |
$remote_addr | internalProxies="127\.0\.0\.1" | 403 | 403 |
Read the table from the top. Without the valve, anyone who reaches Nginx gets the Manager. With the valve but the usual $proxy_add_x_forwarded_for, Nginx appends the real address to the forged one and the valve skips both, because both look internal. Tomcat settles on 127.0.0.1, so any client on a private network walks past the localhost rule with one header. Either fix alone closed it in our test. We use both, because each one protects you from a mistake in the other’s config, and the 403 in the last row is the result you want. Only the Manager password stood between a forged header and a deploy button in the second row.
Make both Tomcat changes in server.xml:
sudo vim /etc/tomcat/server.xml
Add address="127.0.0.1" to the 8080 connector, so nothing reaches Tomcat except through Nginx, and add the RemoteIpValve line inside the <Host> element. These are the edited parts of the file on our Rocky box:
<Connector port="8080" protocol="HTTP/1.1" address="127.0.0.1"
connectionTimeout="20000"
redirectPort="8443"
maxParameterCount="1000"
/>
...
<Host name="localhost" appBase="webapps"
unpackWARs="true" autoDeploy="true">
<Valve className="org.apache.catalina.valves.RemoteIpValve" internalProxies="127\.0\.0\.1" remoteIpHeader="X-Forwarded-For" protocolHeader="X-Forwarded-Proto" />
After sudo systemctl restart tomcat, ss -ltn showed 8080 on 127.0.0.1 only, and the test JSP printed scheme=https secure=true. From a workstation it printed the workstation’s own address, forged header or not. With the subnet override back in place, the Manager answered the admin workstation again; outside that subnet the CIDR rule applies, which is the 403 the table shows. Delete the test app when you’re done with sudo rm -rf /var/lib/tomcat/webapps/scheme, because it shows client addresses to anyone who finds it.
The Server Status page shows the valve at work. The forwarded client and the actual client (127.0.0.1, which is Nginx) appear in separate columns, and the connector name confirms the localhost binding:

Now the proxy_http_version 1.1 line. Rocky’s Nginx talks HTTP/1.0 to Tomcat without it, and Tomcat answers HTTP/1.0 requests that need a trailing slash with an absolute redirect built from the connector’s own scheme. Requesting https://tomcat.example.com/sample through our proxy redirected to http://tomcat.example.com/sample/, a silent downgrade, even with RemoteIpValve in place. A direct HTTP/1.1 request to Tomcat got a relative Location: /sample/, and once Nginx used HTTP/1.1 the same request through the proxy redirected to /sample/ too, staying on HTTPS.
Install Tomcat 11 from apache.org
For Tomcat 11, install the upstream tarball under /opt/tomcat and run it from your own unit. It needs Java 17 or newer: Rocky’s JDK 21 or either distro’s JDK 25 qualifies. Detect the latest 11.0 release from the Apache download site:
TC_VER=$(curl -s https://dlcdn.apache.org/tomcat/tomcat-11/ | grep -oE "v11\.[0-9]+\.[0-9]+/" | sort -V | tail -1 | tr -d v/)
echo $TC_VER
It printed the current release on our Fedora 44 box:
11.0.27
Download the archive from the CDN and its checksum from the main Apache server, then verify. Tomcat’s .sha512 file includes the file name, so sha512sum -c reads it directly:
cd /tmp
curl -sSLO https://dlcdn.apache.org/tomcat/tomcat-11/v${TC_VER}/bin/apache-tomcat-${TC_VER}.tar.gz
curl -sSLO https://downloads.apache.org/tomcat/tomcat-11/v${TC_VER}/bin/apache-tomcat-${TC_VER}.tar.gz.sha512
sha512sum -c apache-tomcat-${TC_VER}.tar.gz.sha512
If you see anything other than OK, stop.
apache-tomcat-11.0.27.tar.gz: OK
Install a JDK first if the machine has none. The headless package is enough for Tomcat, and it owns the /usr/lib/jvm/jre link the unit below relies on. On Fedora, install java-25-openjdk-headless; on Rocky, java-21-openjdk-headless or the 25 build:
sudo dnf install -y java-25-openjdk-headless
Create a system user that owns the install and cannot log in, then extract the archive into /opt/tomcat. Don’t do this on a machine that also runs the packaged Tomcat; the user name, port 8080 and the unit name would all collide:
sudo useradd -r -U -d /opt/tomcat -s /sbin/nologin tomcat
sudo mkdir -p /opt/tomcat
sudo tar -xzf /tmp/apache-tomcat-${TC_VER}.tar.gz -C /opt/tomcat --strip-components=1
sudo chown -R tomcat:tomcat /opt/tomcat
The unit in the old version of this guide hardcoded JAVA_HOME=/usr/lib/jvm/java-21-openjdk. Fedora 44 and 45 have no JDK 21 package, so on our Fedora 44 box that unit failed on the first start, and its Restart=always kept retrying every ten seconds. The journal named the problem:
The JAVA_HOME environment variable is not defined correctly
JAVA_HOME=/usr/lib/jvm/java-21-openjdk
This environment variable is needed to run this program
NB: JAVA_HOME should point to a JDK not a JRE
Point JAVA_HOME at /usr/lib/jvm/jre instead. It’s a symlink that the distro’s alternatives system keeps on an installed JDK, so the unit survives JDK upgrades on both distros. Our JAVA_HOME guide for Rocky and Fedora explains how those links resolve. Create the unit:
sudo vim /etc/systemd/system/tomcat.service
This is the exact unit that ran on Fedora 44. Restart=on-failure replaces the old always, so a broken config fails once and stays visible in systemctl status instead of looping, and the heap settings are a starting point to size for your apps:
[Unit]
Description=Apache Tomcat 11
After=network.target
[Service]
Type=forking
User=tomcat
Group=tomcat
Environment="JAVA_HOME=/usr/lib/jvm/jre"
Environment="CATALINA_PID=/opt/tomcat/temp/tomcat.pid"
Environment="CATALINA_HOME=/opt/tomcat"
Environment="CATALINA_BASE=/opt/tomcat"
Environment="CATALINA_OPTS=-Xms512M -Xmx1024M"
ExecStart=/opt/tomcat/bin/startup.sh
ExecStop=/opt/tomcat/bin/shutdown.sh
SuccessExitStatus=143
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
Load and start it, then ask the default page for its title.
sudo systemctl daemon-reload
sudo systemctl enable --now tomcat
curl -s http://localhost:8080/ | grep -o "<title>[^<]*</title>"
The title carries the version, so this also confirms which Tomcat answered:
<title>Apache Tomcat/11.0.27</title>
With SELinux enforcing, the tarball Tomcat ran as unconfined_service_t with no relabeling, and served pages without a single denial. The old guide’s list of semanage fcontext commands for /opt/tomcat does not hold up. One of its types, tomcat_etc_t, does not exist (ValueError: Type tomcat_etc_t is invalid). The rest moved Tomcat into tomcat_t, which both distros’ policies mark as an unconfined domain anyway, so you gain nothing from them. Fedora 45’s packaged Tomcat does not even reach tomcat_t; it runs as initrc_t because its new wrapper script is not labeled for the transition. In practice SELinux protects you here through Nginx’s side, the boolean above, not through Tomcat’s.
The Nginx and RemoteIpValve steps apply unchanged to the tarball install, with the files under /opt/tomcat/conf. The tarball’s Manager ships the same localhost-only RemoteCIDRValve and the same LockOutRealm, so the user and override steps carry over with /opt/tomcat/conf in place of /etc/tomcat. What you give up is dnf upgrade: subscribe to the Tomcat 11 security page and repeat the download and extract for each release, keeping your conf/ and webapps/ directories. That upkeep is the real cost of the tarball, and the reason we’d stay on the packaged 10.1 until an app actually needs Jakarta EE 11.