Automating web server setups with ansible

Table of contents

The LAMP (Linux, Apache2, MySQL, PHP) stack has been a common choice for web applications of all kinds for decades. Its popularity makes it a great choice for automating it's deployment and configuration.

Choosing a LAMP stack

When installing a LAMP web server, the four core services to install are obvious. But a web server will commonly need more services to function properly, for example FTP to transfer files. The stack in this article chooses to install this additional software:

  • PHPMyAdmin to provide a visual user interface to the MySQL database.
  • logrotate to keep the size of log files in check
  • sFTP through openssh-server to allow upload of web application contents
  • certbot to automate SSL certificates

For simplicity, the playbook assumes that all hosts run a debian-like linux distribution (Debian, Ubuntu, Mint, ...).

A sample inventory

Before starting the playbook, we need an inventory defining some metadata about the host servers used to install the LAMP stacks on. Here is a sample inventory:

all:
 hosts:
   my_server: # nickname of the server     
     
     # SSH connection info for this server
     ansible_host: 192.168.56.110
     ansible_user: sysadmin
     ansible_password: "123"

     # variables to customize the LAMP stack install
     vhost_domain: example.com
     vhost_alt_domains: [www.example.com, app.example.com]
     mysql_root_pass: "123"
     ftp_user: my_user
     ftp_pass: "123"
     enable_ssl: true
     ssl_email: admin@example.com

In addition to ansible's SSH connection variables, the sample inventory provides a lot of custom variables for the LAMP stack. Each of these will be explained together with the playbook section responsible for it. The playbook will be designed to use sane defaults if any variable is undefined.

Install required packages

The first step is to install the packages needed to run the stack. Ideally, this should be the first step and install all packages at once, as running multiple install steps may query the package cache unnecessarily and turn out to be fairly slow:

---
- name: LAMP stack setup
  hosts: all
  become: true
  tasks:
   - name: Install required packages
     apt:
       name:
        - apache2
        - php
        - php-mysql
        - php-xml
        - php-mbstring
        - php-zip
        - php-curl
        - php-bz2
        - php-opcache
        - libapache2-mod-php
        - mariadb-server
        - phpmyadmin
        - logrotate
       update_cache: true
       state: present

The installation includes some common PHP extensions to avoid incompatibilities with common web software. You may need to add moer specialized extensions depending on the web application you plan to host.

Note the update_cache: true line at the bottom, ensuring that the package cache is updated before installing the packages, so only the most recent versions are installed (even if the local package cache was outdated when running the playbook).

Configure apache2

The apache2 web server will be mostly functional out of the box, but we may need to alter the virtualhost to account for the desired domain(s) it should serve content for:

  - name: Set primary domain for virtualhost
    when: vhost_domain is defined
    lineinfile:
      path: /etc/apache2/sites-available/000-default.conf
      regexp: ServerName
      line: "ServerName {{ vhost_domain }}"
      insertbefore: DocumentRoot
  - name: Set alternative domains for virtualhost
    when: vhost_alt_domains is defined
    lineinfile:
      path: /etc/apache2/sites-available/000-default.conf
      regexp: ServerAlias
      line: "ServerAlias {{ vhost_alt_domains | join(' ') }}"
      insertbefore: DocumentRoot

The first step will set the ServerName directive to the primary domain used for the virtualhost, using the variable vhost_domain from the inventory. If the variable is missing, the virtualhost will not be assigned a ServerName directive, effectively making it a catch-all for all incoming requests regardless of domain.

If one or more vhost_alt_domains were specified for the server through the inventory, they are added to the virtualhost configuration through the ServerAlias directive in the second step. The difference between the primary and alternative domains for a virtualhost is negligible for HTTP configurations, but when using SSL, the server will redirect incoming requests to the primary domain.

Lastly, we need to adjust the ServerTokens directive in the apache2 security config:

  - name: Adjust apache2 security config
    copy:
      dest: /etc/apache2/conf-available/security.conf
      content: |
        ServerTokens Prod
        ServerSignature Off
        TraceEnable Off

Setting this directive to Prod ensures that the server will not expose the actual apache2 version or activated modules through headers in the HTTP response. Any web server facing untrusted connections (i.e. the internet) should use this setting to minimize the information an attacker may gain from fingerprinting, limiting their ability to exploit version-specific bugs.

Prepare MySQL and PHPMyAdmin

To enable PHPMyAdmin, we first need to link it's apache2 configuration into the conf-available directory, then enable it and finally restart apache2:

  - name: Link phpmyadmin config to apache2
    file:
      src: /etc/phpmyadmin/apache.conf
      dest: /etc/apache2/conf-available/phpmyadmin.conf
      state: link
  - name: Enable phpmyadmin config in apache2
    command: a2enconf phpmyadmin
  - name: Restart apache2
    service:
      name: apache2
      state: restarted
      enabled: true

The web UI will be available at http://server_domain/phpmyadmin.

The MySQL root user password is set using the mysql_root_pass inventory variable:

  - name: Set mysql root user password
    command: mysqladmin -u root password "{{ mysql_root_pass }}"
  - name: Start and enable mysql server
    service:
      name: mariadb
      state: started
      enabled: true

Finally, the mariadb service is enabled to run at boot and started.

Set up logrotate

If left unmaintained, log files can grow considerably in size and fill up all remaining disk space. To combat this issue, we set up logrotate to periodically move and compress log file contents:

  - name: Configure logrotate for Apache2 and MySQL
    copy:
      dest: /etc/logrotate.d/lamp
      content: |
        /var/log/apache2/*.log {
          daily
          rotate 14
          compress
          delaycompress
          missingok
          notifempty
          create 640 root adm
        }
        /var/log/mysql/*.log {
          daily
          rotate 14
          compress
          delaycompress
          missingok
          notifempty
          create 640 mysql adm
        }

The configuration will rotate log files from apache2 and mysql once per day, keep up to 14 previous versions and compress rotated logs. This also ensures that log file contents are deleted after 14 days, avoiding issues with privacy laws like GDPR.

Enable sFTP access

To enable users to access the webserver files over FTP, we set up a new user account from the ftp_user inventory variable and set their password using ftp_pass. The new user is then added to the sftp_users group, which is used to identify them as an sftp-only user in the openssh-server:

  - name: Create sFTP user
    user:
      name: "{{ ftp_user }}"
      password: "{{ ftp_pass | password_hash('sha512') }}"
      shell: /usr/sbin/nologin
      state: present
      create_home: true
  - name: Set up SFTP-only group
    group:
      name: sftp_users
      state: present
  - name: Add user to SFTP-only group
    user:
      name: "{{ ftp_user }}"
      groups: sftp_users
      append: yes
  - name: Set permissions on /var/www/html for SFTP user
    file:
      path: /var/www/html
      owner: root
      group: sftp_users
      mode: '0775'
      state: directory
  - name: Set correct permissions for chroot
    file:
      path: /var/www
      owner: root
      group: root
      mode: '0755'
      state: directory
  - name: Configure SSH for SFTP-only access to /var/www
    lineinfile:
      path: /etc/ssh/sshd_config
      line: |
        Match Group sftp_users
          ChrootDirectory /var/www
          ForceCommand internal-sftp
          AllowTcpForwarding no
          X11Forwarding no
  - name: Restart SSH
    service:
      name: sshd
      state: restarted

Since the user has a login shell of /usr/sbin/nologin and sftp_users is jailed to /var/www, they can only log in over sFTP (port 22) and access the web directory.

Automate SSL certificates

Web servers hosting websites available to the internet should use SSL to encrypt traffic to protect their visitors. Certbot is an obvious choice for automating SSL certificate retrieval and maintenance:

  - name: Certbot SSL automation
    when: enable_ssl is defined and enable_ssl and ssl_email is defined and vhost_domain is defined
    block:
     - name: Install certbot
       apt:
         name:
          - certbot
          - python3-certbot-apache
         state: present
         update_cache: yes
     - name: Obtain and install SSL certificates
       command: "certbot --apache -d {{ ([vhost_domain] + (vhost_alt_domains | default([]))) | join(' ') }} --noninteractive --agree-tos --email {{ ssl_email }}"

Note how the certbot installation and certificate retrieval are grouped into a block that only executes if enable_ssl is true and ssl_email has been set, to prevent common errors.

The certbot package is installed in a separate step instead of adding it to the package installation in the first step as it is optional and especially local/testing servers have no need for it.

Since the certbot package sets up its own cronjob for certificate renewal, it is enough to retrieve a certificate for the vhost domain(s) from the playbook.

More articles

The downsides of source-available software licenses

And how it differs from real open-source licenses

Configure linux debian to boot into a fullscreen application

Running kiosk-mode applications with confidence

How to use ansible with vagrant environments

Painlessly connect vagrant infrastructure and ansible playbooks