In order to be able to execute these templates saved in Gitea, we will need to prepare the environment in AWX.
terraform-provisioner) and set its permissions to Read & Write.Once the VPS is set up in Hetzner, the outputs.tf will help us to get the public ipv4 or ipv6 address of the host and we will be able to add the host into our inventory to manage it later.
Create an AWX API Token:
Now, let's store this token securely. Create the credential
AWX Controller Token# Input configuration
fields:
- id: AWX_TOKEN
label: AWX Token
type: string
secret: true
# Injector Configuration:
env:
AWX_TOKEN: '{{ AWX_TOKEN }}'
AWX API TokenAWX Controller TokenHetzner Cloud Token type:# Input Configuration
fields:
- id: HCLOUD_TOKEN
label: Hetzner API Token
type: string
secret: true
# Injector Configuration
env:
HCLOUD_TOKEN: '{{ HCLOUD_TOKEN }}'
Then go to Resources -> Credentials and create a new credential using this type. Paste in your Hetzner API token.
In AWX UI, go to Resources → Inventory and select ‘Add inventory’ from the dropdown.
Hetznerlocalhost---
ansible_connection: local
In order to set up the Site to Site VPN with Site 1 and Site 2 later on, providing the fact that the IP address issued by Hetzner may change during (re-)provisioning of the VPS, we should maintain a DNS record that Site 1 and Site 2 use to connect to Site 3’s endpoint and update it dynamically upon the VPS creation. Unless you have a CloudFlare token already set up, follow these steps to create it.
Zone - DNS - EditInclude - Specific zone - bachelor-tech.comCloudFlare API Token# Input configuration:
fields:
- id: cloudflare_api_token
label: CloudFlare API Token
type: string
secret: true
# Injector configuration:
extra_vars:
cloudflare_api_token: '{{ cloudflare_api_token }}'
CloudFlare (bachelor-tech.com)CloudFlare API Token.In case you have created the YAML and Terraform files in a new repo, you will need to ensure that they are pulled into AWX. If you are working on an existing repo, then just run a sync job.
Gitea - InfrastructureGitH1. Provision Galera Witness - HetznerRunAWX API Token credential and the Hetzner API Token credential and the CloudFlare API token (so 3 in total!).Gitea - Infrastructure project.provision-witness.yml (This should pop up from your Gitea project).community.general for Terraform to work).localhost item we created earlier.Lots of things can go wrong, starting from little YAML-related syntax mistakes to certain commands not working on your distro or version of choice.
Potential issues before the VPS is created:
community.general in your EE → cannot call Terraformhetzner.hcloud in your EE → cannot interface with Hetzneransible_connection: local in your localhost host results in errors related to the inability to match a hostprovision-witness-hetzner.yml) does not start with the #cloud-config line on the first line, making it non-recognizable when executed on the VPS.Error: name is already used (uniqueness_error, 97cc8f7bf626fbe0084738ed7d6b0cdd) with hcloud_firewall.witness_fw, it means that you are running the job again, removed the VPS but forgot to remove the firewall rule in Hetzner.Some challenges you may run after the VPS creation:
cloud-init.log and cloud-init-output.log. Let me know in the comments below if you get stuck or would like to understand what the errors in the logs mean (unless you prefer to use AI to interpret for you!).root for the username and manually type in the generated password. You will get into the VPS. Check the following logs:# The output from cloud-init (the initial commands)
sudo nano /var/log/cloud-init.log
# The output of the RUN commands
sudo nano /var/log/cloud-init-output.log
We now have our VPS in our inventory and can reach its public IP address via SSH to run additional jobs. What we want is to automate the set up of a Site 2 Site VPN with the other two sites + to configure garbd, so that our VPS can start acting as a witness.
cloud-init has finished installing all base packages.wg0.conf) using Jinja2 templates and secrets from AWX, then enables the service.garb configuration, sets up log rotation for the arbitrator logs, and starts the service to join the cluster.**# 2-configure-witness.yml**
---
- name: 1. Verify Witness is Ready
hosts: galera-witness-hetzner
gather_facts: no # Don't try to gather facts until we know it's online
pre_tasks:
- name: Wait for SSH port (2222) to be available
ansible.builtin.wait_for:
host: "{{ ansible_host | default(inventory_hostname) }}"
port: "{{ ansible_port | default(2222) }}"
state: started
delay: 5 # Wait 5s before first check
timeout: 300 # Wait up to 5 minutes
delegate_to: localhost # Run this check from the AWX container
become: false # No need for sudo
- name: Wait for cloud-init to finish
ansible.builtin.command:
cmd: cloud-init status --wait
changed_when: false
become: true # This must run with sudo
- name: 2. Configure WireGuard on Witness
hosts: galera-witness-hetzner
become: true
tasks:
- name: Ensure /etc/wireguard directory exists
ansible.builtin.file:
path: /etc/wireguard
state: directory
owner: root
group: root
mode: '0700' # drwx------
- name: Create WireGuard wg0.conf
ansible.builtin.template:
src: wg0.conf.j2
dest: /etc/wireguard/wg0.conf
owner: root
group: root
mode: '0600'
notify: Restart wireguard
- name: Ensure WireGuard starts on boot
ansible.builtin.systemd_service:
name: wg-quick@wg0
enabled: yes
state: started
handlers:
- name: Restart wireguard
ansible.builtin.systemd_service:
name: wg-quick@wg0
state: restarted
- name: 3. Configure Galera Arbitrator (garbd)
hosts: galera-witness-hetzner
become: true
tasks:
- name: Create and set permissions for garbd.log
ansible.builtin.file:
path: /var/log/garbd.log
state: touch
owner: nobody
group: nogroup
mode: '0644'
- name: Create garb configuration
ansible.builtin.template:
src: garb.default.j2
dest: /etc/default/garb
owner: root
group: root
mode: '0644'
notify: Restart garb
- name: Add logrotate configuration for garb
ansible.builtin.copy:
dest: /etc/logrotate.d/garb
content: |
/var/log/garbd.log
{
daily
rotate 7
compress
delaycompress
missingok
notifempty
create 0644 nobody nogroup
}
owner: root
group: root
mode: '0644'
- name: Ensure garbd starts on boot
ansible.builtin.systemd_service:
name: garb # The service name is called garb, not garbd
enabled: yes
state: started
handlers:
- name: Restart garb
ansible.builtin.systemd_service:
name: garb
state: restarted
**# wg0.conf.j2**
[Interface]
# This is the witness node's configuration
Address = {{ witness_wg_ip | default('10.10.10.3/24') }}
ListenPort = 51821
PrivateKey = {{ witness_wg_private_key }}
# --- Peer 1: Site 1 - U vody (OPNSense) ---
[Peer]
PublicKey = {{ site1_wg_public_key }}
Endpoint = {{ site1_wg_endpoint | default('uvody.bachelor-tech.com:51821') }}
AllowedIPs = 192.168.8.0/24, 10.10.10.1/32
# --- Peer 2: Site 2 - Tusarka (OPNSense) ---
[Peer]
PublicKey = {{ site2_wg_public_key }}
Endpoint = {{ site2_wg_endpoint | default('tusarka.bachelor-tech.com:51821') }}
AllowedIPs = 192.168.6.0/24, 10.10.10.2/32
GALERA_NODES), listing all other nodes in the cluster so the arbitrator knows who to connect to.gmcast.segment) to ensure the witness participates in voting and is treated as a separate segment for latency reasons.**# garb.default.j2**
# Configuration for Galera Arbitrator
# This file is sourced by /usr/bin/garb-systemd
# Cluster name from your 60-galera.cnf
GALERA_GROUP="clusterA"
# List of ALL *DATA NODES* (Sites 1 & 2)
GALERA_NODES="192.168.8.71:4567,192.168.8.72:4567,192.168.8.73:4567,192.168.8.74:4567,192.168.6.75:4567,192.168.6.76:4567"
# Set the segment for this witness node
GALERA_OPTIONS="gmcast.segment=3"
# Log file location
LOG_FILE="/var/log/garbd.log"
witness_private.key and witness_public.key. We will store the private key in its own credential type.wg genkey | tee witness_private.key | wg pubkey > witness_public.key
Hetzner_Witness_peer10.10.10.3/32 (just the interface)51821WireGuard Private Key# Input Configuration
fields:
- id: private_key
label: WireGuard Private Key
type: string
secret: true
# Injector Configuration
extra_vars:
witness_wg_private_key: '{{ private_key }}'
Witness WG Private KeyWireGuard Private Key type.witness_private.key file.In order for the Galera Arbitrator communication to occur from Site 3 with Site 1+2, we need to open a port on the VPN tunnel to pass traffic on TCP and UDP port 4567. Here is an example with OPNSense that is located on Site 1 and Site 2. You will need to apply this rule on each.
PassWG S2S VPNinIPv4TCP/UDP10.10.10.3/32 (Site 3 VPN)LAN net4567 to 4567 (this is the port that garb uses, unlike SQL)Log packets that are handled by this ruleAllow Galera Witness Inufw (or another local firewall service like iptables) running on each of your galera nodes, you will need to open ports for the communication with the Arbitrator over the S2S VPN to work on port 4567 TCP+UDP:
ufw, SSH into each Galera node (Site 1 + Site 2) and run the following using the IP of the tunnel of Site 3:sudo ufw allow from 10.10.10.0/24 to any port 4567 proto tcp
sudo ufw allow from 10.10.10.0/24 to any port 4567 proto udp
H2 - Configure Galera WitnessHetznergalera-witness-hetzner2-configure-witness.yml - if you do not see it, sync your playbook from the Project section first to fetch it from Gitea.ansible - the SSH key we use to log into VMs and Site 3 WG private key---
# These are all public and safe to store as plain text
site1_wg_public_key: "PASTE_SITE1_PUBLIC_KEY_HERE"
site1_wg_endpoint: "site1:51821"
site2_wg_public_key: "PASTE_SITE2_PUBLIC_KEY_HERE"
site2_wg_endpoint: "site2:51821"
witness_wg_ip: "10.10.10.3/24"
mysql -u root -p
SHOW STATUS LIKE 'wsrep_cluster_size';
garb (or garbd) service is up on Site 3.With both templates working end to end, the last part adds a restoration job and joins everything into one workflow you can trigger with a single click.