Introduction

Apache HTTP Server (httpd) remains the world's most deployed web server. Ansible automates every aspect — installation, virtual host management, SSL certificates, module configuration, reverse proxy setup, and performance tuning. Template your entire Apache configuration from variables so adding a site is just adding an item to a YAML list.

Install Apache

---
- name: Deploy Apache web server
  hosts: webservers
  become: true
  tasks:
    - name: Install Apache (Debian/Ubuntu)
      ansible.builtin.apt:
        name:
          - apache2
          - libapache2-mod-ssl
        state: present
      when: ansible_os_family == 'Debian'

    - name: Install Apache (RHEL/CentOS)
      ansible.builtin.dnf:
        name:
          - httpd
          - mod_ssl
        state: present
      when: ansible_os_family == 'RedHat'

    - name: Start Apache
      ansible.builtin.service:
        name: "{{ 'apache2' if ansible_os_family == 'Debian' else 'httpd' }}"
        state: started
        enabled: true

Virtual Hosts

- name: Deploy virtual hosts
  ansible.builtin.template:
    src: vhost.conf.j2
    dest: >-
      {{ '/etc/apache2/sites-available/' if ansible_os_family == 'Debian'
         else '/etc/httpd/conf.d/' }}{{ item.domain }}.conf
    mode: '0644'
  loop: "{{ apache_vhosts }}"
  notify: reload apache

- name: Enable sites (Debian)
  ansible.builtin.command: a2ensite {{ item.domain }}
  args:
    creates: "/etc/apache2/sites-enabled/{{ item.domain }}.conf"
  loop: "{{ apache_vhosts }}"
  when: ansible_os_family == 'Debian'
  notify: reload apache

- name: Disable default site
  ansible.builtin.command: a2dissite 000-default
  args:
    removes: /etc/apache2/sites-enabled/000-default.conf
  when: ansible_os_family == 'Debian'
  notify: reload apache
# templates/vhost.conf.j2
<VirtualHost *:80>
    ServerName {{ item.domain }}
{% if item.aliases is defined %}
{% for alias in item.aliases %}
    ServerAlias {{ alias }}
{% endfor %}
{% endif %}
    DocumentRoot {{ item.docroot | default('/var/www/' ~ item.domain ~ '/html') }}

{% if item.redirect_https | default(true) %}
    RewriteEngine On
    RewriteCond %{HTTPS} off
    RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
{% else %}
    <Directory {{ item.docroot | default('/var/www/' ~ item.domain ~ '/html') }}>
        Options -Indexes +FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>
{% endif %}

    ErrorLog ${APACHE_LOG_DIR}/{{ item.domain }}-error.log
    CustomLog ${APACHE_LOG_DIR}/{{ item.domain }}-access.log combined
</VirtualHost>

{% if item.ssl | default(false) %}
<VirtualHost *:443>
    ServerName {{ item.domain }}
{% if item.aliases is defined %}
{% for alias in item.aliases %}
    ServerAlias {{ alias }}
{% endfor %}
{% endif %}
    DocumentRoot {{ item.docroot | default('/var/www/' ~ item.domain ~ '/html') }}

    SSLEngine on
    SSLCertificateFile {{ item.ssl_cert | default('/etc/letsencrypt/live/' ~ item.domain ~ '/fullchain.pem') }}
    SSLCertificateKeyFile {{ item.ssl_key | default('/etc/letsencrypt/live/' ~ item.domain ~ '/privkey.pem') }}

    # Modern SSL config
    SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
    SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
    SSLHonorCipherOrder off

    # Security headers
    Header always set Strict-Transport-Security "max-age=63072000"
    Header always set X-Content-Type-Options nosniff
    Header always set X-Frame-Options DENY

    <Directory {{ item.docroot | default('/var/www/' ~ item.domain ~ '/html') }}>
        Options -Indexes +FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>

{% if item.proxy_pass is defined %}
    # Reverse proxy
    ProxyPreserveHost On
    ProxyPass / {{ item.proxy_pass }}/
    ProxyPassReverse / {{ item.proxy_pass }}/
{% endif %}

    ErrorLog ${APACHE_LOG_DIR}/{{ item.domain }}-ssl-error.log
    CustomLog ${APACHE_LOG_DIR}/{{ item.domain }}-ssl-access.log combined
</VirtualHost>
{% endif %}

Variables

apache_vhosts:
  - domain: app.example.com
    aliases: [www.app.example.com]
    ssl: true
    proxy_pass: http://127.0.0.1:3000

  - domain: static.example.com
    docroot: /var/www/static
    ssl: true
    redirect_https: true

  - domain: api.example.com
    ssl: true
    proxy_pass: http://127.0.0.1:8080

Enable Modules

- name: Enable Apache modules (Debian)
  community.general.apache2_module:
    name: "{{ item }}"
    state: present
  loop:
    - rewrite
    - ssl
    - headers
    - proxy
    - proxy_http
    - proxy_wstunnel
    - expires
    - deflate
  when: ansible_os_family == 'Debian'
  notify: restart apache

Reverse Proxy

- name: Deploy reverse proxy config
  ansible.builtin.copy:
    dest: /etc/apache2/sites-available/proxy.conf
    content: |
      <VirtualHost *:443>
          ServerName app.example.com
          SSLEngine on
          SSLCertificateFile /etc/ssl/certs/app.crt
          SSLCertificateKeyFile /etc/ssl/private/app.key

          ProxyPreserveHost On
          ProxyPass / http://127.0.0.1:3000/
          ProxyPassReverse / http://127.0.0.1:3000/

          # WebSocket support
          RewriteEngine On
          RewriteCond %{HTTP:Upgrade} websocket [NC]
          RewriteCond %{HTTP:Connection} upgrade [NC]
          RewriteRule ^/?(.*) ws://127.0.0.1:3000/$1 [P,L]

          # Proxy headers
          RequestHeader set X-Forwarded-Proto "https"
          RequestHeader set X-Real-IP "%{REMOTE_ADDR}s"
      </VirtualHost>
    mode: '0644'
  notify: reload apache

Performance Tuning

- name: Configure MPM event
  ansible.builtin.template:
    src: mpm_event.conf.j2
    dest: /etc/apache2/mods-available/mpm_event.conf
    mode: '0644'
  notify: restart apache
# templates/mpm_event.conf.j2
<IfModule mpm_event_module>
    StartServers             {{ apache_start_servers | default(4) }}
    MinSpareThreads          {{ apache_min_spare_threads | default(25) }}
    MaxSpareThreads          {{ apache_max_spare_threads | default(75) }}
    ThreadLimit              64
    ThreadsPerChild          25
    MaxRequestWorkers        {{ apache_max_workers | default(150) }}
    MaxConnectionsPerChild   {{ apache_max_conn_per_child | default(10000) }}
</IfModule>
- name: Enable compression
  ansible.builtin.copy:
    dest: /etc/apache2/conf-available/compression.conf
    content: |
      <IfModule mod_deflate.c>
          AddOutputFilterByType DEFLATE text/html text/plain text/xml
          AddOutputFilterByType DEFLATE text/css text/javascript
          AddOutputFilterByType DEFLATE application/javascript application/json
          AddOutputFilterByType DEFLATE application/xml application/xhtml+xml
          DeflateCompressionLevel 6
      </IfModule>
    mode: '0644'

- name: Enable browser caching
  ansible.builtin.copy:
    dest: /etc/apache2/conf-available/caching.conf
    content: |
      <IfModule mod_expires.c>
          ExpiresActive On
          ExpiresByType image/jpeg "access plus 1 year"
          ExpiresByType image/png "access plus 1 year"
          ExpiresByType text/css "access plus 1 month"
          ExpiresByType application/javascript "access plus 1 month"
      </IfModule>
    mode: '0644'

Security Hardening

- name: Security configuration
  ansible.builtin.copy:
    dest: /etc/apache2/conf-available/security-hardening.conf
    content: |
      # Hide version
      ServerTokens Prod
      ServerSignature Off

      # Disable TRACE
      TraceEnable Off

      # Prevent clickjacking
      Header always set X-Frame-Options "SAMEORIGIN"
      Header always set X-Content-Type-Options "nosniff"
      Header always set Referrer-Policy "strict-origin-when-cross-origin"
      Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"

      # Disable directory listing
      <Directory />
          Options -Indexes
          AllowOverride None
          Require all denied
      </Directory>
    mode: '0644'
  notify: reload apache

Troubleshooting

Validate Config

- name: Test Apache configuration
  ansible.builtin.command: >
    {{ 'apache2ctl' if ansible_os_family == 'Debian' else 'httpd' }} configtest
  register: config_test
  changed_when: false
  failed_when: "'Syntax OK' not in config_test.stderr"

Check Virtual Hosts

- name: List enabled virtual hosts
  ansible.builtin.command: apache2ctl -S
  register: vhost_list
  changed_when: false

Conclusion

Ansible templates Apache virtual hosts from YAML variables — adding a site is adding an item to a list. Use community.general.apache2_module to manage modules, template-based configs for SSL, reverse proxy, and performance tuning, and configtest validation before applying changes. Apache + Ansible gives you declarative multi-site web server management across your fleet.