# Tomcat SSL: install a Let's Encrypt certificate with PEM files

> Configure HTTPS on Apache Tomcat 10.1/11 with PEM certificate files from Let's Encrypt, enable TLS 1.2+1.3, redirect HTTP and restart Tomcat on renewal.

Source: https://ssl.sh/guides/tomcat-ssl/
Last updated: 2026-11-10

Apache Tomcat reads a Let's Encrypt certificate directly from PEM files in an `SSLHostConfig` connector on port 443, so no Java keystore is needed. Tomcat redirects plain HTTP to HTTPS, and a Certbot deploy hook copies renewed files and restarts Tomcat.

The steps apply to Tomcat 10.1 and 11.0 (and 9.0 with the same `SSLHostConfig` syntax) on Linux with Java 17 or later and Certbot 4.1 or later.

## Prerequisites

Certbot needs port 80 free for its standalone challenge server, and Tomcat needs permission to bind port 443.

- Tomcat installed as a systemd service running as user `tomcat`, with `CATALINA_BASE` at `/opt/tomcat` (adjust paths to your install).
- Certbot from snap: `sudo snap install --classic certbot && sudo ln -s /snap/bin/certbot /usr/local/bin/certbot`.
- DNS `A`/`AAAA` records for `example.com` pointing at the server, for example `203.0.113.10`.
- Inbound TCP 80 and 443 open to all sources; nothing else listening on port 80.

## Step 1: Obtain the certificate

With port 80 free, Certbot's standalone mode runs its own temporary web server for the HTTP-01 challenge:

```bash
sudo certbot certonly --standalone -d example.com -d www.example.com
```

The files land in `/etc/letsencrypt/live/example.com/`: `cert.pem` (leaf), `chain.pem` (intermediates), `fullchain.pem` and `privkey.pem`.

## Step 2: Copy the files where Tomcat can read them

`/etc/letsencrypt/live` is readable only by root. A deploy hook copies the files with the right owner after every issuance and renewal:

```bash
sudo tee /etc/letsencrypt/renewal-hooks/deploy/tomcat.sh > /dev/null <<'EOF'
#!/bin/sh
set -e
dest=/opt/tomcat/conf/ssl
install -d -o tomcat -g tomcat -m 700 "$dest"
install -o tomcat -g tomcat -m 644 "$RENEWED_LINEAGE/cert.pem"    "$dest/cert.pem"
install -o tomcat -g tomcat -m 644 "$RENEWED_LINEAGE/chain.pem"   "$dest/chain.pem"
install -o tomcat -g tomcat -m 600 "$RENEWED_LINEAGE/privkey.pem" "$dest/privkey.pem"
systemctl restart tomcat
EOF
sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/tomcat.sh
sudo RENEWED_LINEAGE=/etc/letsencrypt/live/example.com /etc/letsencrypt/renewal-hooks/deploy/tomcat.sh
```

## Step 3: Configure the HTTPS connector

Edit `/opt/tomcat/conf/server.xml`. Add an HTTPS connector on 443 and point the HTTP connector's `redirectPort` at it:

```xml
<Connector port="80" protocol="HTTP/1.1"
           connectionTimeout="20000" redirectPort="443" />

<Connector port="443" protocol="org.apache.coyote.http11.Http11NioProtocol"
           SSLEnabled="true" scheme="https" secure="true" maxThreads="150">
    <UpgradeProtocol className="org.apache.coyote.http2.Http2Protocol" />
    <SSLHostConfig protocols="+TLSv1.2+TLSv1.3">
        <Certificate certificateFile="conf/ssl/cert.pem"
                     certificateKeyFile="conf/ssl/privkey.pem"
                     certificateChainFile="conf/ssl/chain.pem" />
    </SSLHostConfig>
</Connector>
```

Relative paths resolve against `CATALINA_BASE`. Do not put `fullchain.pem` into `certificateChainFile`; that attribute must not include the server certificate.

## Step 4: Allow port 443 and force HTTPS

An unprivileged Java process cannot bind ports below 1024 by default. Grant the capability in the systemd unit:

```bash
sudo systemctl edit tomcat
```

```ini
[Service]
AmbientCapabilities=CAP_NET_BIND_SERVICE
```

To redirect every request to HTTPS, add a security constraint to `conf/web.xml` (or to your application's `web.xml`) before `</web-app>`:

```xml
<security-constraint>
    <web-resource-collection>
        <web-resource-name>Entire site</web-resource-name>
        <url-pattern>/*</url-pattern>
    </web-resource-collection>
    <user-data-constraint>
        <transport-guarantee>CONFIDENTIAL</transport-guarantee>
    </user-data-constraint>
</security-constraint>
```

Because Tomcat now holds port 80, renewals must not use standalone mode on port 80. Switch the renewal to webroot, serving challenges from the ROOT webapp:

```bash
sudo systemctl daemon-reload && sudo systemctl restart tomcat
sudo certbot reconfigure --cert-name example.com --webroot -w /opt/tomcat/webapps/ROOT
```

## Verify

The endpoint should present the Let's Encrypt certificate followed by its intermediates, and HTTP should redirect to HTTPS. Check it with the [SSL checker](https://ssl.sh/tools/ssl-checker/), or from any terminal:

```bash
curl ssl.sh/example.com
```

On the server, confirm the chain and the redirect:

```bash
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null | grep -E '^ *[0-9] s:'
curl -sI http://example.com | head -n 3
sudo journalctl -u tomcat -n 50 --no-pager | grep -i -E 'ssl|443'
```

## Automatic renewal

The Certbot snap's systemd timer runs `certbot renew` twice a day and follows Let's Encrypt's ARI window (Certbot 4.1 and later). After each renewal the deploy hook from Step 2 copies the new files and restarts Tomcat. Run a dry run to check the challenge path:

```bash
sudo certbot renew --dry-run
```

Dry runs skip deploy hooks, so after the first real renewal confirm the new expiry date. Restarting Tomcat drops active connections briefly; Tomcat also exposes a JMX operation to reload `SSLHostConfig` without a restart (check the documentation for your version). Add the site to [ssl.sh monitoring](https://ssl.sh/monitor/) (in development) because Let's Encrypt no longer sends expiry emails.

## Troubleshooting

- **java.net.BindException: Permission denied** on port 443: the capability from Step 4 is missing; run `sudo systemctl daemon-reload && sudo systemctl restart tomcat`.
- **Tomcat starts but the connector fails with a key error**: `privkey.pem` is not readable by the `tomcat` user; re-run the deploy hook.
- **Java or Android clients fail with PKIX errors**: the chain is wrong; see [PKIX path building failed](https://ssl.sh/errors/java-pkix-path-building-failed/) and [missing intermediate certificate](https://ssl.sh/errors/missing-intermediate-certificate/).
- **Renewal fails with timeout during connect**: the webroot path is not served at `/.well-known/acme-challenge/`; see [certbot timeout during connect](https://ssl.sh/errors/certbot-timeout-during-connect/).
- **ERR_SSL_PROTOCOL_ERROR**: the browser reached a plain HTTP connector on 443, or `SSLEnabled` is missing; see [ERR_SSL_PROTOCOL_ERROR](https://ssl.sh/errors/err-ssl-protocol-error/).
