Simple WordPress setup with 1Password, Terraform, Ansible, and Docker on Linode

In this post we will be setting up a WordPress installation on Linode using 1Password to store our secrets, Terraform to manage the server resources, and Docker (WordPress + Nginx) for running the site.

Table of Contents


1Password

1Password is a password and secrets manager that offers powerful CLI tooling in addition to a pleasant UI. Follow the setup instructions for the CLI here and then setup the 1Password SSH-agent as described here.

Terraform

In order to use Terraform with 1Password we need to wrap terraform with a script.1 Put the following in tfproxy.sh

#!/bin/sh
op run --env-file="./linode.env" -- terraform "$@"

Allow the file to be executed with chmod +x tfproxy.sh. Then populate linode.env with

AWS_ACCESS_KEY_ID = "op://My Vault/linode_terraform_token/username"
AWS_SECRET_ACCESS_KEY = "op://My Vault/linode_terraform_token/password"
TF_VAR_linode_cli_token = "op://My Vault/Linode Personal Access Token/token"

Bootstrapping with Linode

In the previous step we specified a few passwords that are going to be used by Terraform. Before we can populate them we need to setup an object storage bucket to store the Terraform state which you can do here. For this post we assume the location is us-east-1 and we recommend a name such as tf-state-myusername.

Now we need to populate 1Password “My Vault” vault with the Linode keys.

  • linode_terraform_token username and password should be set to point to a bucket token that can be generated here.
  • Linode Personal Access Token token points to a token generated from here.

Then create a Terraform file called main.tf

Terraform {
  required_providers {
    linode = {
      source  = "linode/linode"
      version = "2.13.0"
    }
    ansible = {
      source  = "ansible/ansible"
      version = "~> 1.1.0"
    }
  }
  backend "s3" {
    skip_s3_checksum            = true
    skip_credentials_validation = true
    endpoint                    = "https://us-east-1.linodeobjects.com"
    skip_requesting_account_id  = true
    bucket                      = "<my bucket>"
    key                         = "infra/state.json"
    region                      = "us-east-1"
  }
}
provider "linode" {
  token = var.linode_cli_token
}

Notice that we need to include the settings for skip_s3_checksum, skip_credentials_validations and skip_requesting_account_id for Terraform to work with Linode as a backend.

Now run ./tfproxy.sh init.

Creating the Machine

Add the following to main.tf

resource "linode_instance" "wordpress" {
  label           = "wordpress"
  image           = "linode/ubuntu22.04"
  region          = var.region
  type            = "g6-nanode-1"
  authorized_keys = [var.ssh_key]
  backups_enabled = true
}
output "wordpress_ip" {
  value = resource.linode_instance.wordpress.ipv4
}

and create a file terraform.tfvars with your public SSH key which you can find using ssh-add -l or (for 1Password on macOS) SSH_AUTH_SOCK=~/Library/Group\ Containers/2BUA8C4S2C.com.1password/t/agent.sock ssh-add -l

ssh_key = "ssh-ed25519 AAAAA foo@example.com"

To create the server first run ./tfproxy.sh plan and if everything looks good, ./tfproxy.sh apply.

Ansible

Now we want to provision the server created by Terraform with Ansible. We’ve already added the ansible provider to main.tf so now we just need to provision an ansible_host resource by adding the following to main.tf

resource "ansible_host" "wordpress" {
    name = tolist(resource.linode_instance.wordpress.ipv4)[0]
    groups = ["wordpress"]
}

Rerun ./tfproxy.sh plan and ./tfproxy.sh apply.

Now add the following to inventory.yml

plugin: cloud.terraform.terraform_provider
binary_path: "./tfproxy.sh"

And view the inventory using ansible-inventory -i inventory.yml --list

{
    "_meta": {
        "hostvars": {}
    },
    "all": {
        "children": [
            "ungrouped",
            "wordpress"
        ]
    },
    "wordpress": {
        "hosts": [
            "XX.XX.XX.XX"
        ]
    }
}

Docker

Create an Ansible playbook called docker.yml and put the following in it:

- name: Install docker
  tags: setup
  hosts: wordpress
  remote_user: root
  tasks:
    # https://medium.com/@GarisSpace/how-to-install-docker-using-ansible-01a674086f8c
    - name: Update and upgrade all packages to the latest version
      ansible.builtin.apt:
        update_cache: true
        upgrade: dist
        cache_valid_time: 3600
    - name: Install required packages
      ansible.builtin.apt:
        pkg:
          - apt-transport-https
          - ca-certificates
          - curl
          - gnupg
          - software-properties-common
    - name: Add Docker GPG apt Key
      apt_key:
        url: https://download.docker.com/linux/ubuntu/gpg
        state: present
    - name: Print architecture variables
      ansible.builtin.debug:
        msg: "Architecture: {{ ansible_architecture }}, Codename: {{ ansible_lsb.codename }}"
    - name: Add Docker repository
      ansible.builtin.apt_repository:
        repo: >-
          deb https://download.docker.com/linux/ubuntu {{ ansible_lsb.codename }} stable
        filename: docker
        state: present
    - name: Install Docker and related packages
      ansible.builtin.apt:
        name: "{{ item }}"
        state: present
        update_cache: true
      loop:
        - docker-ce
        - docker-ce-cli
        - containerd.io
        - docker-buildx-plugin
        - docker-compose-plugin
    - name: Add Docker group
      ansible.builtin.group:
        name: docker
        state: present
    - name: Add user to Docker group
      ansible.builtin.user:
        name: "{{ ansible_user }}"
        groups: docker
        append: true
    - name: Enable and start Docker services
      ansible.builtin.systemd:
        name: "{{ item }}"
        enabled: true
        state: started
      loop:
        - docker.service
        - containerd.service
    - name: Install pip
      apt:
        name: python3-pip
    - name: install python docker
      pip:
        name: docker
    - name: install python docker-compose
      pip:
        name: docker-compose

Then create a wordpress.yml playbook with the following:

---
- import_playbook: docker.yml
- name: Setup wordpress
  tags: compose
  remote_user: root
  hosts: wordpress
  tasks:
    - name: create wp-content folder
      ansible.builtin.file:
        path: /srv/wp-content
        state: directory
    - name: update wp-content folder
      ansible.posix.synchronize:
        src: wp-content/
        dest: /srv/wp-content
    - name: copy nginx config
      ansible.builtin.copy:
        src: nginx/
        dest: /srv/nginx/
    - name: set wp-content permissions
      ansible.builtin.file:
        path: /srv/wp-content
        owner: 1002
        group: 1002
        recurse: yes
    - name: create wordpress compose
      ansible.builtin.template:
        src: wordpress_compose.yml.j2
        dest: /srv/wordpress_compose.yml
    - name: start the server
      community.docker.docker_compose:
        project_src: /srv
        files:
          - wordpress_compose.yml
    - name: reload nginx
      ansible.builtin.shell: docker compose -f wordpress_compose.yml exec nginx nginx -s reload
      args:
        chdir: /srv

Now make a directory called wp-content.You can put any themes or plugins you want in there later. Also create a path for Nginx configurations: mkdir -p nginx/conf. Put the following in nginx/conf/app.conf:

server {
    listen 80;
    listen [::]:80;
    server_name YOURDOMAIN.com www.YOURDOMAIN.com;
    server_tokens off;
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
    location / {
        return 301 https://YOURDOMAIN.com$request_uri;
    }
}

Now’s a good time to point an A DNS record for your domain to the server and replace YOURDOMAIN with, well, your domain.

You also need a Docker compose template file called wordpress_compose.yml.j2

version: "3.9"
services:
  nginx:
    image: nginx:stable-alpine
    volumes:
      - ./nginx/conf:/etc/nginx/conf.d/:ro
      - ./etc/letsencrypt:/etc/letsencrypt:ro
      - ./certbot/data:/var/www/certbot
    restart: always
    ports:
      - 80:80
      - 443:443
    depends_on:
      - wordpress
  certbot:
    image: certbot/certbot:latest
    depends_on:
      - nginx
    command: >-
             certonly --reinstall --webroot --webroot-path=/var/www/certbot
             --email you@example.com --agree-tos --no-eff-email
             -d YOURDOMAIN.com
    volumes:
      - ./etc/letsencrypt:/etc/letsencrypt
      - ./certbot/data:/var/www/certbot
  db:
    image: mysql:8.0.23
    ports:
      - 3306:3306
    volumes:
      - db_data:/var/lib/mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: {{ lookup('community.general.onepassword', 'wp_mysql_root', field='password', vault='My Vault') }}
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: {{ lookup('community.general.onepassword', 'wp_mysql', field='password', vault='My Vault') }}
  wordpress:
    depends_on:
      - db
    image: wordpress:latest
    user: 1002:1002
    ports:
      - 8080:80
    restart: always
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: {{ lookup('community.general.onepassword', 'wp_mysql', field='password', vault='My Vault') }}
    volumes:
      - type: bind
        source: /srv/wp-content/
        target: /var/www/html/wp-content
volumes:
  db_data: {}

Then generate usernames and passwords for wp_mysql and wp_mysql_root in 1Password.

What’s going on here? This playbook runs the Docker setup playbook, copies over wp-content, sets the wp-content user to the users inside the Docker container (very important to allow for WordPress to modify the folder), fills in a template for docker compose, starts the compose, and then refreshes Nginx.

Run the playbook to completion.

ansible-playbook -i inventory.yml wordpress.yml

SSL

Once the services have started you should have an SSL key now. To be sure, check the log output:

ssh root@my-ip
cd /srv
docker compose -f wordpress_compose.yml logs

If the SSL certificate was successfully obtained you can now tell Nginx to use it by adding the following to the bottom of nginx/conf/app.conf:

server {
    listen 443 default_server ssl http2;
    listen [::]:443 ssl http2;
    server_name YOURDOMAIN.com;
    ssl_certificate /etc/letsencrypt/live/YOURDOMAIN.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/YOURDOMAIN.com/privkey.pem;
    proxy_redirect off;
    location / {
        # https://blog.ldev.app/running-wordpress-behind-ssl-and-nginx-reverse-proxy/
		proxy_set_header	X-Real-IP	$remote_addr;
		proxy_set_header	X-Forwarded-For	$proxy_add_x_forwarded_for;
		proxy_set_header	X-Forwarded-Proto	$scheme;
		proxy_set_header	Host	$host;
		proxy_set_header	X-Forwarded-Host	$host;
		proxy_set_header	X-Forwarded-Port	$server_port;
		proxy_set_header	Upgrade	$http_upgrade;
		proxy_set_header	Connection	"Upgrade";
    	proxy_pass http://wordpress:80/;
    }
}

Notice that we cannot add this until the certificate is generated because otherwise Nginx will not start. Update the configuration and have Nginx reload using the following command (which skips installing Docker):

ansible-playbook -i inventory.yml wordpress.yml --skip-tags setup

And that’s it!