Introduction

Apache Tomcat serves Java Servlets, JSPs, and WebSocket applications. Ansible automates the complete lifecycle — install JDK, deploy Tomcat, configure connectors, manage WAR files, tune the JVM, set up SSL, and perform zero-downtime deployments. This guide covers standalone through clustered production setups.

Install JDK and Tomcat

---
- name: Deploy Apache Tomcat
  hosts: app_servers
  become: true
  vars:
    tomcat_version: "10.1.28"
    tomcat_major: "10"
    java_package: java-17-openjdk
    tomcat_user: tomcat
    tomcat_home: /opt/tomcat
    tomcat_port: 8080
    tomcat_shutdown_port: 8005
  tasks:
    - name: Install Java JDK
      ansible.builtin.package:
        name: "{{ java_package }}"
        state: present

    - name: Create Tomcat user
      ansible.builtin.user:
        name: "{{ tomcat_user }}"
        home: "{{ tomcat_home }}"
        shell: /bin/false
        system: true

    - name: Download Tomcat
      ansible.builtin.get_url:
        url: "https://dlcdn.apache.org/tomcat/tomcat-{{ tomcat_major }}/v{{ tomcat_version }}/bin/apache-tomcat-{{ tomcat_version }}.tar.gz"
        dest: /tmp/tomcat.tar.gz
        checksum: "sha512:https://dlcdn.apache.org/tomcat/tomcat-{{ tomcat_major }}/v{{ tomcat_version }}/bin/apache-tomcat-{{ tomcat_version }}.tar.gz.sha512"

    - name: Extract Tomcat
      ansible.builtin.unarchive:
        src: /tmp/tomcat.tar.gz
        dest: "{{ tomcat_home }}"
        remote_src: true
        extra_opts: [--strip-components=1]
        owner: "{{ tomcat_user }}"
        group: "{{ tomcat_user }}"
        creates: "{{ tomcat_home }}/bin/catalina.sh"

    - name: Deploy server.xml
      ansible.builtin.template:
        src: server.xml.j2
        dest: "{{ tomcat_home }}/conf/server.xml"
        owner: "{{ tomcat_user }}"
        mode: '0600'
      notify: restart tomcat

    - name: Deploy tomcat-users.xml
      ansible.builtin.template:
        src: tomcat-users.xml.j2
        dest: "{{ tomcat_home }}/conf/tomcat-users.xml"
        owner: "{{ tomcat_user }}"
        mode: '0600'
      notify: restart tomcat

    - name: Create systemd service
      ansible.builtin.template:
        src: tomcat.service.j2
        dest: /etc/systemd/system/tomcat.service
        mode: '0644'
      notify:
        - daemon reload
        - restart tomcat

    - name: Start Tomcat
      ansible.builtin.service:
        name: tomcat
        state: started
        enabled: true

  handlers:
    - name: daemon reload
      ansible.builtin.systemd:
        daemon_reload: true

    - name: restart tomcat
      ansible.builtin.service:
        name: tomcat
        state: restarted

Systemd Service

# templates/tomcat.service.j2
[Unit]
Description=Apache Tomcat {{ tomcat_major }}
After=network.target

[Service]
Type=forking
User={{ tomcat_user }}
Group={{ tomcat_user }}

Environment="JAVA_HOME=/usr/lib/jvm/jre"
Environment="CATALINA_HOME={{ tomcat_home }}"
Environment="CATALINA_BASE={{ tomcat_home }}"
Environment="CATALINA_PID={{ tomcat_home }}/temp/tomcat.pid"
Environment="CATALINA_OPTS={{ tomcat_catalina_opts | default('-Xms512m -Xmx1024m -XX:+UseG1GC') }}"

ExecStart={{ tomcat_home }}/bin/startup.sh
ExecStop={{ tomcat_home }}/bin/shutdown.sh

RestartSec=10
Restart=always

[Install]
WantedBy=multi-user.target

Deploy WAR Files

- name: Deploy application WAR
  ansible.builtin.copy:
    src: "{{ war_file }}"
    dest: "{{ tomcat_home }}/webapps/{{ app_name }}.war"
    owner: "{{ tomcat_user }}"
    mode: '0644'
  notify: restart tomcat

# Or download from artifact repository
- name: Deploy WAR from Nexus
  ansible.builtin.get_url:
    url: "https://nexus.example.com/repository/releases/com/myorg/myapp/{{ app_version }}/myapp-{{ app_version }}.war"
    dest: "{{ tomcat_home }}/webapps/myapp.war"
    url_username: "{{ nexus_user }}"
    url_password: "{{ vault_nexus_password }}"
    owner: "{{ tomcat_user }}"
    mode: '0644'
  notify: restart tomcat

Zero-Downtime Deployment

---
- name: Zero-downtime WAR deployment
  hosts: app_servers
  become: true
  serial: 1
  tasks:
    - name: Remove from load balancer
      ansible.builtin.uri:
        url: "http://{{ lb_host }}/api/backends/{{ inventory_hostname }}/disable"
        method: POST
      delegate_to: localhost

    - name: Wait for connections to drain
      ansible.builtin.pause:
        seconds: 30

    - name: Undeploy old version
      ansible.builtin.file:
        path: "{{ tomcat_home }}/webapps/{{ app_name }}.war"
        state: absent

    - name: Wait for undeploy
      ansible.builtin.wait_for:
        path: "{{ tomcat_home }}/webapps/{{ app_name }}"
        state: absent
        timeout: 60

    - name: Deploy new version
      ansible.builtin.copy:
        src: "builds/{{ app_name }}-{{ app_version }}.war"
        dest: "{{ tomcat_home }}/webapps/{{ app_name }}.war"
        owner: "{{ tomcat_user }}"

    - name: Wait for deployment
      ansible.builtin.uri:
        url: "http://localhost:{{ tomcat_port }}/{{ app_name }}/health"
        status_code: 200
      register: health
      until: health.status == 200
      retries: 30
      delay: 5

    - name: Re-enable in load balancer
      ansible.builtin.uri:
        url: "http://{{ lb_host }}/api/backends/{{ inventory_hostname }}/enable"
        method: POST
      delegate_to: localhost

JVM Tuning

tomcat_catalina_opts: >-
  -Xms{{ tomcat_heap_min | default('1g') }}
  -Xmx{{ tomcat_heap_max | default('2g') }}
  -XX:+UseG1GC
  -XX:MaxGCPauseMillis=200
  -XX:+HeapDumpOnOutOfMemoryError
  -XX:HeapDumpPath={{ tomcat_home }}/logs/
  -Djava.awt.headless=true
  -Dfile.encoding=UTF-8
  -Djava.security.egd=file:/dev/urandom

SSL/TLS Configuration

<!-- In server.xml template -->
<Connector port="8443" protocol="org.apache.coyote.http11.Http11NioProtocol"
           maxThreads="150" SSLEnabled="true"
           scheme="https" secure="true">
    <SSLHostConfig>
        <Certificate certificateKeystoreFile="{{ tomcat_home }}/conf/keystore.p12"
                     certificateKeystorePassword="{{ vault_keystore_password }}"
                     type="RSA" />
    </SSLHostConfig>
</Connector>
- name: Create Java keystore from PEM
  ansible.builtin.command: >
    keytool -importkeystore
    -srckeystore /tmp/cert.p12
    -srcstoretype PKCS12
    -srcstorepass {{ vault_keystore_password }}
    -destkeystore {{ tomcat_home }}/conf/keystore.p12
    -deststoretype PKCS12
    -deststorepass {{ vault_keystore_password }}
  no_log: true

Manager App Access

<!-- templates/tomcat-users.xml.j2 -->
<?xml version="1.0" encoding="UTF-8"?>
<tomcat-users>
  <role rolename="manager-gui"/>
  <role rolename="manager-script"/>
  <role rolename="admin-gui"/>
  <user username="{{ tomcat_admin_user }}"
        password="{{ vault_tomcat_admin_password }}"
        roles="manager-gui,manager-script,admin-gui"/>
</tomcat-users>

Health Monitoring

- name: Check Tomcat status
  ansible.builtin.uri:
    url: "http://localhost:{{ tomcat_port }}"
    status_code: 200
  register: tomcat_health

- name: Check deployed applications
  ansible.builtin.uri:
    url: "http://localhost:{{ tomcat_port }}/manager/text/list"
    url_username: "{{ tomcat_admin_user }}"
    url_password: "{{ vault_tomcat_admin_password }}"
    force_basic_auth: true
  register: app_list

- name: Show deployed apps
  ansible.builtin.debug:
    var: app_list.content

Troubleshooting

OutOfMemoryError

Increase heap in CATALINA_OPTS:

tomcat_heap_max: "4g"

Port Already in Use

- name: Check port availability
  ansible.builtin.wait_for:
    port: "{{ tomcat_port }}"
    state: stopped
    timeout: 5
  ignore_errors: true
  register: port_check

Conclusion

Ansible automates Tomcat from install to production — JDK setup, Tomcat extraction, server.xml templating, WAR deployment from build artifacts, JVM tuning, SSL configuration, and rolling zero-downtime updates with serial: 1. Use systemd services for process management and the Manager API for health monitoring. Every Tomcat configuration is a template; every deployment is idempotent.