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
Related Articles
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.