Skip to content


This blog is running a WordPress, using Ubuntu, Apache and MySQL. So it’s a very basic installation.

I made all this with a tiny Scaleway VM and Ansible. My Goal has been to install this thing without actually having to log into the VM (“Look Mom, no hands!”). Of course, I have been logging into the VM, but that’s mostly for checking things are going well.

MySQL Installation from Hell

Ubuntu being a Debian descendant, I am running into a few problems. For example, when installing things such as MySQL from a package, debianoid systems have a tendency to start things from the package immediately.

That is of course completely silly, because that’s a start with defaults, as the configuration is not yet in place. How can I install a thing such as MySQL from a .deb without the server being started implicitly?

At the moment I am doing the trifecta backwards, which is kind of not the thing I want to do for a number of reasons.

- name: MySQL installation defaults file
    src: "mysql-debconf-defaults"
    dest: "/tmp/mysql-debconf-defaults"
    owner: "root"
    group: "root"
    mode: "0600"

- name: set MySQL defaults
  shell: debconf-set-selections < /tmp/mysql-debconf-defaults; rm /tmp/mysql-debconf-defaults
    removes: "/tmp/mysql-debconf-defaults"

- name: create config directory
    path: "/etc/mysql/conf.d"
    recurse: "yes"
    state: "directory"

- name: create blog.cnf
    src: "blog.cnf"
    dest: "/etc/mysql/conf.d/blog.cnf"

- name: create /root/.my.cnf
    src: "dotmy.cnf"
    dest: "/root/.my.cnf"
    owner: "root"
    group: "root"
    mode: "0600"
# will also start the server instead of deferring to a handler
- name: install MySQL server
    name: "mysql-server-5.7"

This is annoying in multiple ways:

The installation is interactive by default and I have install a debconf file, run debconf-set-selections and then remove the file, which is not idempotent and creates an action on every run (the file contains a password, so I cannot leave it laying around).

The autostart at package installation precludes proper service startup control. Using notifies and a handler, I would only have one restart for package installation and config file changes.

Instead I have to install config files before package installation, in order to prevent the server from starting without a configuration, and then later the server is autostarted by the package installation. Because of the ordering and the autostart, I cannot use notifications and handlers on the config file installations properly, so I am missing automated restarts on config changes.

Boring Apache install

The Apache installation is pretty boring: Apache whatever from Ubuntu, and the necessary PHP 7 modules. Apparently the required extensions are curl, gd, mcrypt and mysql, with their respective dependencies.

Automated WordPress

Looking into Ansible playbooks for WordPress installation, they usually confine themselves to a prepackages Debfile installation or to curling the current WordPress and dropping it into webroot.

That won’t do, we also want to be able to config the wordpress without clicking.

Adding a user

In my case, I need a wordpress:wordpress user and group for the installation.

- name: add a wordpress group
    name: wordpress
    state: present
    gid: 1000

- name: add a wordpress user
    name: wordpress
    state: present
    uid: 1000
    group: wordpress
    home: /var/www/{{ sitename }}
    shell: /bin/false
    comment: Wordpress Owner

This will be the file owner for everything not writeable by the webserver, later.

Downloading and unpacking

Unfortunately, having a more recent WordPress is safer (and hence more important) than having a packaged install. So, we get_url our stuff and drop it manually. Again, that’s hard to make idempotent.

At least the download is available as SSL (but of course, no cert checking and checksumming, what do you expect?).

- name: download wordpress
    dest: /root/wordpress-latest.tar.gz
    mode: 0400

- name: unpack wordpress
    remote_src: yes
    src: /root/wordpress-latest.tar.gz
    dest: /root
    owner: wordpress
    group: wordpress

- name: move archive into place
  shell: "mv /root/wordpress/* /var/www/{{ sitename }}"
    creates: /var/www/{{ sitename }}/xmlrpc.php

MySQL/Wordpress Integration

WordPress needs a database and a user. That’s easily done in Ansible.

- name: create database for blog
    name: "{{ database }}"
    state: present

- name: create database user for blog
    name: "{{ database_user }}"
    password: "{{ database_password }}"
    priv: "{{ database }}.*:all"
    state: present

Amend config files for Apache and PHP, drop WP config in place

Now the config part of the installation. We need to speak to Apache, to PHP and to WP itself.

- name: install wp config
    src: wp-config.php
    dest: /var/www/{{ sitename }}/wp-config.php
    mode: '0644'
    owner: root

- name: install apache config
    dest: /etc/apache2/sites-available/{{ sitename }}.conf
    mode: '0644'
    owner: root
    group: root

- name: amend php config
    src: 99-kris.ini
    dest: /etc/php/7.0/conf.d/99-kris.ini
    mode: '0644'
    owner: root
    group: root
  notify: reload apache

- name: enable apache config
  command: a2ensite {{ sitename }}
  notify: reload apache
    creates: /etc/apache2/sites-enabled/{{ sitename }}.conf

That leaves us with a working, but mostly unconfigured blog. We now need to talk to the MySQL used by WordPress, or we need a tool.

There is a Tool:


wp-cli is a commandline tool in PHP which loads code from the actual WordPress proper and then enables calling WP internals from the commandline.

We download wp-cli, drop a wp-cli install script and run the wp-cli Script to do stuff. We are installing an image or another asset as part of the install to make the cli run idempotent (that’s cheating, but the best we can do).

- name: install wp-cli
    dest: /usr/local/bin/wp
    mode: 0755
    owner: root
    group: root

- name: install title image for import
    src: strandkorb.jpg
    dest: /tmp/strandkorb.jpg
    owner: wordpress
    group: wordpress
    mode: 0644

- name: install favicon
    src: favicon.ico
    dest: /var/www/{{ sitename }}/favicon.ico
    owner: wordpress
    group: wordpress
    mode: 0644

- name: install wp install script
    src: install.wp
    dest: /tmp/install.wp
    owner: wordpress
    group: wordpress
    mode: 0755

- name: run wp install script
  shell: sudo -u wordpress bash -c "( cd /var/www/{{ sitename }}; bash -xv /tmp/install.wp )"
    creates: /var/www/{{ sitename }}/wp-content/uploads/strandkorb.jpg

The actual work is now done all by the install.wp script.

Using wp-cli

So in order to really config the blog we need to wp-cli the shit out of it.

Basic installation and variables incoming like so:

# basic install
wp core install --url={{ sitename }} --title=Isoblog --admin_user={{ wp_user }} --admin_password={{ wp_password }} --skip-email
wp option set blogdescription "This is my blog. There are many like it, but this is mine."
wp option set start_of_week 0
wp option set posts_per_rss 50
wp option set default_ping_status "closed"
wp option set date_format "Y-m-d"
wp option set time_format "H:i"
wp option set permalink_structure "/index.php/%post_id%-%postname%/"
wp option set gmt_offset     1
wp option set comment_registration ""
wp option set avatar_default retro
wp option set close_comments_for_old_posts ""
wp option set close_comments_days_old        180
wp option set thread_comments_depth  5

Next up is the template and plugin cleanup:

# clean up templates, activate ours
wp theme install author
wp theme activate author
wp theme delete twentyfifteen
wp theme delete twentyseventeen
wp theme delete twentysixteen
# get rid of unneeded plugins
wp plugin delete hello

We then are going to use a number of plugins which need config:

# configure akismet (preinstalled)
wp option add wordpress_api_key {{ akismet_key }}
wp option add akismet_strictness 0
wp option add akismet_show_user_comments_approved 0
wp plugin activate akismet
# install google sitemap, no config
wp plugin install google-sitemap-generator
wp plugin activate google-sitemap-generator
# install simple comment editing
wp plugin install simple-comment-editing
wp plugin activate simple-comment-editing
# install google 2FA
wp plugin install google-authenticator
wp plugin activate google-authenticator
# configure user to use this
wp user meta add kris googleauthenticator_description Isoblog
wp user meta add kris googleauthenticator_enabled 1
wp user meta add kris googleauthenticator_secret {{ ga_secret }}
# install syntax highlighting (no config)
wp plugin install wp-syntax
wp plugin activate wp-syntax
# install wp-optimize (no config)
wp plugin install wp-optimize
wp plugin activate wp-optimize
# security
wp plugin install wordfence
wp plugin activate wordfence
wp plugin install security-ninja
wp plugin activate security-ninja
# get rid of the default page and post
wp comment delete 1
wp post delete 1
wp post delete 2
# upload a title banner, to not enable it
wp option set uploads_use_yearmonth_folders ""
wp media import --porcelain /tmp/strandkorb.jpg
wp option set uploads_use_yearmonth_folders 1

I think the logic here is pretty clear: We are using “wp plugin install” and “wp plugin activate” to get stuff, then use whatever is necessary to push the config (mostly “wp option add”).

Actual documentation on this is scarce, but having mysqldump diffs and being able to read them helps a lot.

All of this is a rough draft, but seems to mostly work on my test blog.

Published inBlogErklärbär


  1. towo

    There’s a slighty hacky (but great to automate) way to do it, and it’s also the officially intended one: policy-rc.d.

    Yes, it’s a PITA, that’s why it’s rare to find tutorials for it, since it’s a policy script that’s supposed to parse things.

    But if you want to generally forbid it, just put “exit 101” into a bash script at /usr/sbin/policy-rc.d.

    Slightly more details at, full spec at

    • kris kris

      This is great stuff, I need to check this out in detail later today.

  2. Andre

    Debian starts the database server to install its “debian-sys-maint” backdoor user, alter a few tables in mysql and enable a few plugins…

    And while it’s running already, it’s probably easier to keep it that way.

    In Chef, I would just add a conditional “only_if” statement to the mysql block to check, if my WordPress table is already there…

    For the MySQL configuration file, Chef compares the current file and the candidate it will roll out and only restart MySQL if they actually differ… Ansible must have some mechanism for that, too.. I never got very friendly with the yaml configuration, that’s why I prefer Chef :D

    • kris kris

      The “onlyif” is normally implied in all Ansible operations. So the Ansible MySQL module checks if a database is present before it create one, and so on.

      • Andre

        Then it shouldn’t be a problem to roll out your mysql configurations and not care about the unconfigured mysql running already or am I missing something?

        • kris kris

          An unconfigured MySQL may or may not have default users with no passwords, may be bound to * instead of localhost or have other properties that are unwanted (for example, it may create redo logs in /var/lib/mysql when you may want them in /mysql/data, because that is on a persistent and detachable volume).

          In general it is a very stupid idea to couple package installation and service start. The trifecta is Package, Configuration, Service Start in this order for a reason.

          • Andre

            In a modern, configuration-managed world, it is :)

            Back in the day, we were happy when a service came up :D

            On the other hand.. there were always those services that were added to init, but not started and those that have been started after installation.

            A little more conformity would probably go a longer way than just fixing the MySQL package :)

  3. Actually there are checksums for the wordpress-download (well hidden). Just add sha1 or md5 to the download-url:

    Then you could use the checksum-option of get_url. According to the docs, the file-download would be skipped if the checksum is the same.

    You could download the checksum and then pass it as a variable to get_url.
    The most idempotency you could get, I guess…

Leave a Reply

Your email address will not be published. Required fields are marked *