In this tutorial, we will automate deployment of a VPS on Hetzner (a renowned EU cloud-based provider) using Terraform and configure it using Ansible - all triggered from AWX. This is a definitive guide for anyone running Galera on hybrid cloud infrastructure!
AWX will run the following three templates as part of one workflow:
With a click of a button, you will be able to spin up a fully configured VPS for < 4 EUR a month in an environment of a trusted European-based cloud provider and be able to re-create it whenever or wherever needed 🙂
For an easier visualization, here is a flowchart diagram:
While the Site 1 & 2 configuration is out of the scope of this tutorial, we have already explored which firewall ports will need to be opened and how to troubleshoot them (at least on OPNSense).
In case you have been running your Galera cluster on just one site for now, you may have noticed that the config file for the Arbitrator contains a term ‘segment’. What is it?
In its default setting, each node carries one weight. In the proposed architecture section earlier, you may have noticed that Site 2 has only 2x Galera nodes, whereas Site 1 has 4x nodes. So how can you influence weighting on the galera nodes?
An example for Site 2’s Galera node config where we need to set:
segment: 2weight for each node: 2sudo nano /etc/mysql/mariadb.conf.d/60-galera.cnf
[galera]
# ... your other settings ...
# Site 2 Specifics: Segment 2, Weight 2
wsrep_provider_options="gcache.size=512M;gcs.fc_limit=128;gcs.fc_factor=0.8;**gmcast.segment=2;pc.weight=2**"
# Full Cluster List
wsrep_cluster_address="gcomm://192.168.**8**.71,192.168.**8**.72,192.168.**8**.73,192.168.**8**.74,192.168.**6**.75,192.168.**6**.76"
# Node Specifics (Example for A5)
wsrep_node_address = "192.168.6.75"
wsrep_node_name = "galera-a5"
wsrep_sst_receive_address = "192.168.6.75"
[galera]
# ... your other config ...
wsrep_provider_options="gcache.size=512M;gcs.fc_limit=128;gcs.fc_factor=0.8;gmcast.**segment=1;pc.weight=1**"
mysql -u root -p
# With 4+4+1 design, you should see 9
SHOW STATUS LIKE 'wsrep_cluster_weight';
# With 4 nodes + 2 nodes + 1 witness, you should see 7
SHOW STATUS LIKE 'wsrep_cluster_size';
requirements.yml as well as related dependencies in requirements.txt - check out this part of my previous guide).
hetzner.hcloud - to provision the VPS and firewall rules on Hetznercommunity.general - for CloudFlare DNS record changes, Terraformansible.netcommon - sudo elevation on the VPSawx.awx - to add the VPS into our AWX inventorygoogle.cloud - to interface with a GCP bucketcommunity.docker - to work with a Docker container for UptimeKumakuma.db (backup) of Uptime Kuma to restore from a GCP bucket.Firstly, we will create our scripts and based on that, we will create the credential types and add the credentials in. This will hopefully help shed some light on what we are doing and why.
main.tf: This file is the main set of instructions. It defines the provider, finds the right OS image, and creates the server with your exact specifications. See Terraform manual.1a-provision-witness-terraform.yml - use Terraform to provision the VPS in Hetzner (before cloud-init below is used).1b-provision-witness-hetzner.yml - the cloud-init script that will install and configure services on the VPS. See Hetzner’s manual.outputs.tf: This file tells Terraform what information to print out when it's done. This is critical for AWX integration.hcloud (Hetzner Cloud) provider to manage resources.CX23 VPS (2 vCPU, 4GB RAM) in the Falkenstein data center (fsn1).1b-provision-witness-hetzner.yml user data to handle OS-level setup immediately upon boot.witness_fw) that strictly limits ingress traffic to SSH, WireGuard, and Uptime Kuma ports (this is external to ufw that also gets installed on the VPS later using the 1b template).**#** **main.tf
**
# This tells Terraform we are using the Hetzner Cloud provider
terraform {
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1" # Use the latest 1.x version
}
}
}
# The provider will automatically use the HCLOUD_TOKEN environment variable
provider "hcloud" {}
# ----------------------------
# --- DEFINE VM PROPERTIES ---
# ----------------------------
# This data block finds the latest "debian-13" image ID
data "hcloud_image" "debian_image" {
name = "debian-13"
with_architecture = "x86"
}
# This data block finds your SSH key to add to the server.
data "hcloud_ssh_key" "jan_key" {
name = "Jan's key 2025-06"
}
data "hcloud_ssh_key" "ansible_key" {
name = "Ansible"
}
# This is the main resource block that creates the VM
resource "hcloud_server" "witness_vm" {
name = "galera-witness"
server_type = "cx23" # 2 vCPU, 4GB RAM, 40GB SSD
image = data.hcloud_image.debian_image.id
location = "fsn1" # Falkenstein (eu-central)
# Enable/disable ipv4 and ipv6
public_net {
ipv4_enabled = true
ipv6_enabled = false
}
# Add your SSH key for initial access (before cloud-init runs)
ssh_keys = [
data.hcloud_ssh_key.jan_key.id,
data.hcloud_ssh_key.ansible_key.id
]
# This reads the cloud-init for Hetzner and passes it to the server
user_data = file("1b-provision-witness-hetzner.yml")
labels = {
"service" = "galera"
"role" = "witness"
}
}
# -------------------------------------------------
# --- DEFINE THE HETZNER FIREWALL AND ITS RULES ---
# -------------------------------------------------
resource "hcloud_firewall" "witness_fw" {
name = "galera-witness-fw"
# Rule 1: Allow SSH (on your new port) from anywhere
rule {
direction = "in"
protocol = "tcp"
port = "2222"
source_ips = [
"0.0.0.0/0",
"::/0"
]
}
# Allow WireGuard (UDP) from Site 1 and Site 2
rule {
direction = "in"
protocol = "udp"
port = "51821"
source_ips = [
"0.0.0.0/0",
"::/0"
]
}
# Rule 3: Allow Galera (TCP/UDP) from VPN subnets
rule {
direction = "in"
protocol = "tcp"
port = "4567"
source_ips = [
"192.168.0.0/16",
"10.10.10.0/24"
]
}
rule {
direction = "in"
protocol = "udp"
port = "4567"
source_ips = [
"192.168.0.0/16",
"10.10.10.0/24"
]
}
# Rule 4: Allow ICMP (Ping)
rule {
direction = "in"
protocol = "icmp"
source_ips = [
"0.0.0.0/0",
"::/0"
]
}
# Rule 5: Allow Uptime Kuma (TCP) from anywhere
# Later, this can be restricted to the Site 1 + 2 and other WG Roadwarrior IP addresses
rule {
direction = "in"
protocol = "tcp"
port = "3001"
source_ips = [
"0.0.0.0/0",
"::/0"
]
}
}
# -----------------------------------------
# --- ATTACH THE FIREWALL TO THE SERVER ---
# -----------------------------------------
resource "hcloud_firewall_attachment" "fw_attachment" {
firewall_id = hcloud_firewall.witness_fw.id
server_ids = [hcloud_server.witness_vm.id]
}
terraform apply to provision the actual infrastructure on Hetzner.Hetzner inventory, allowing subsequent job templates to target it immediately without manual intervention.hetzner-witness.bachelor-tech.com) to point to the new IP, ensuring VPN endpoints remain valid even if the IP changes.# 1a-provision-witness-terraform.yml
---
- name: Provision Hetzner Witness VM with Terraform
hosts: localhost
connection: local
gather_facts: no
tasks:
- name: Run Terraform to create the witness server
community.general.terraform:
project_path: "{{ playbook_dir }}"
state: present # This means "run terraform apply"
force_init: true # This runs "terraform init" first
# This is how the playbook gets the Hetzner token
# from the AWX credential (see step 4)
environment:
HCLOUD_TOKEN: "{{ lookup('env', 'HCLOUD_TOKEN') }}"
# This registers the output of the 'terraform apply' command
register: tf_output
- name: Show the Witness IPv4 Address
ansible.builtin.debug:
msg: "Server '{{ tf_output.outputs.witness_id.value }}' created with IPv4: {{ tf_output.outputs.witness_ipv4.value }}"
- name: Add new VM to AWX Inventory
awx.awx.host:
name: "galera-witness-hetzner"
inventory: "Hetzner" # Or whatever your inventory is called
variables:
ansible_host: "{{ tf_output.outputs.witness_ipv4.value }}"
ansible_port: 2222
ansible_user: ansible
state: present
environment:
# Token for AWX API - adjust your hostname, as required
CONTROLLER_HOST: "{{ lookup('env', 'TOWER_HOST') | default('https://awx.bachelor-tech.com', true) }}"
CONTROLLER_OAUTH_TOKEN: "{{ lookup('env', 'AWX_TOKEN') }}"
CONTROLLER_VERIFY_SSL: false # Set to true if you have valid SSL
- name: Update CloudFlare DNS record
community.general.cloudflare_dns:
zone: "bachelor-tech.com"
record: "hetzner-witness"
type: "A" # A record is for IPv4
value: "{{ tf_output.outputs.witness_ipv4.value }}"
api_token: "{{ cloudflare_api_token }}"
no_log: true # Hides the token from the log output
S2S VPN with AWX from Site 1)ufw firewall rules#cloud-config line or else it will not be recognized and the following will not be applied):**# 1b-provision-witness-hetzner.yml**
#cloud-config
# Add users
users:
- name: jan
groups: users, admin
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBPS+K109p5/R9YjsGrzW5smURig7pOF+ex3BoBW5a9ZISUQW7A9vdOavwHbGZC5oCM7DyexZwQhr1BVdlLFW4X8= ecdsa-key-20250630
- name: ansible
groups: users, admin
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICYSPfWeFXXskRcuSCa8valFv0rCZY8RizM+68MYMa9c
# Install the pre-requisites for adding the repo
package_update: true
packages:
- curl
- gpg
# Required for Docker:
- ca-certificates
- gnupg
- python3-pip
- mariadb-client # To check for Galera cluster size
package_upgrade: true
# Write into the SSH config file
write_files:
- path: /etc/ssh/sshd_config.d/ssh-hardening.conf
content: |
PermitRootLogin no
PasswordAuthentication no
Port 2222
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
MaxAuthTries 2
AllowTcpForwarding no
X11Forwarding no
AllowAgentForwarding no
AuthorizedKeysFile .ssh/authorized_keys
AllowUsers jan ansible
# Run setup commands
runcmd:
# Apply the new SSH port
- systemctl restart sshd
# Manually add the MariaDB repo (from which we will fetch the arbitrator package)
- curl -o /etc/apt/keyrings/mariadb-keyring.pgp https://mariadb.org/mariadb_release_signing_key.pgp
# Fetch MariaDB 11.8.5 compatible with Trixie
- echo "deb [signed-by=/etc/apt/keyrings/mariadb-keyring.pgp] https://deb.mariadb.org/11.8.5/debian trixie main" > /etc/apt/sources.list.d/mariadb.list
# Update and install the packages
- apt-get update
- apt-get install -y fail2ban ufw mc wireguard wireguard-tools rsync galera-arbitrator-4
# Configure them
- printf "[sshd]\nenabled = true\nport = ssh, 2222\nbanaction = iptables-multiport" > /etc/fail2ban/jail.local
- systemctl enable fail2ban
- systemctl start fail2ban
# --- Install Docker ---
- install -m 0755 -d /etc/apt/keyrings
- curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.gpg
- chmod a+r /etc/apt/keyrings/docker.gpg
- echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian trixie stable" > /etc/apt/sources.list.d/docker.list
- apt-get update
- apt-get install -y python3-docker docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Add ansible user to docker group
- usermod -aG docker ansible
- systemctl enable docker
- systemctl start docker
# Configure UFW
- ufw allow 2222/tcp # SSH
- ufw allow 51821/udp # Site-to-site VPN listening port
- ufw allow 3001/tcp # UptimeKuma's web interface
- ufw allow from 192.168.0.0/16 to any port 4567 # Allows the remote LAN to reach the Arbitrator
- ufw allow from 10.10.10.0/24 to any port 4567 # Site-to-site VPN for Galera Arbitrator
# Enable UFW
- ufw --force enable
tf_output.outputs.witness_ipv4.value and use it to update DNS and Inventory. The ipv6 address is provided as an optional extra for those who would prefer to use that, instead (in which case, modify the 1a script as well).# outputs.tf
output "witness_ipv4" {
description = "The public IPv4 address of the witness server."
value = hcloud_server.witness_vm.ipv4_address
}
output "witness_ipv6" {
description = "The public IPv6 address of the witness server."
value = hcloud_server.witness_vm.ipv6_address
}
output "witness_id" {
description = "The ID of the witness server."
value = hcloud_server.witness_vm.id
}
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.In this workflow, we are automating the final piece of a Galera Cluster deployment. We have already provisioned a VPS (Hetzner) and configured VPN networking with garb Arbitrator service. Now, we need to deploy Uptime Kuma to this node to act as a local monitor.
💡 Note
Crucially, we are not starting from scratch. We are restoring a backup from Google Cloud Storage (GCS) so our monitoring history and settings are preserved, and we are configuring the node to monitor itself immediately upon boot. In other words, you will need a pre-prepared
kuma.dbSQLite file that can be fetched.
mysql -u root -p
-- Create a user that can connect from the VPN subnet (10.10.10.x)
CREATE USER 'kuma_monitor'@'10.10.10.%' IDENTIFIED BY 'YOUR_SECURE_PASSWORD';
-- Grant minimal access (USAGE is enough to check status)
GRANT USAGE ON *.* TO 'kuma_monitor'@'10.10.10.%';
FLUSH PRIVILEGES;
ufw on the nodes (or a similar local firewall service like iptables), ensure that the VPS can reach the node(s) for monitoring of the cluster size. It is best to monitor one node from each site or all of them, if you prefer.# Allow SQL connections from the WireGuard VPN subnet
sudo ufw allow from 10.10.10.0/24 to any port 3306 proto tcp
kuma_monitor into AWX. Go to Administration -> Credential Types and click Add.Galera Monitor User# Input configuration:
---
fields:
- id: username
type: string
label: Database Username
- id: password
type: string
label: Database Password
secret: true
# Injector configuration:
---
extra_vars:
db_monitor_user: '{{ username }}'
db_monitor_pass: '{{ password }}'
Then to save content of the credentials, go to Resources -> Credentials and click on the Add button.
Witness DB Monitorkuma_monitorLastly, ensure that on both your Site 1 and Site 2 firewall, the port 3306 is opened on the site tunnel VPN interface. For example, in my case, on OPNSense, go to Firewall → Rules → choose the WireGuard S2S VPN interface and click on the + sign to add a new rule:
PassWG_S2S VPNinIPv4TCP10.10.10.3/32 (the IP of the tunnel on the VPS side)LAN net3306 to 3306tickAllow SQL kuma_monitor inawx-gcs-reader (or similar).Grant Roles:
Storage Object Viewer and Storage Bucket Viewer (beta), which is needed for metadata.Generate a JSON Key:
awx-gcs-reader account in the list of Credentials and click on it.Next, we teach AWX how to understand this new GCP JSON key. Navigate to AWX: Go to Administration -> Credential Types and click on the Add button.
GCP Service Account# Input configuration:
fields:
- id: service_account_json
type: string
label: Service Account JSON
secret: true
# Injector configuration
env:
GCP_SERVICE_ACCOUNT_CONTENTS: '{{ service_account_json }}'
GCP UptimeKuma BackupGCP Service Account from the list.Here is how this Ansible playbook (3-restore-uptimekuma.yml) works, step-by-step.
# 3-restore-uptimekuma.yml
---
- name: 1. Download Backup from GCS (on AWX)
hosts: localhost
connection: local
gather_facts: no
become: false
vars:
gcs_bucket: proxmox-backup-bachelor
gcs_object_path: "hetzner-backup/kuma.db"
local_temp_backup: "/tmp/kuma.db"
gcs_project_id: "113447253568"
tasks:
- name: Download Uptime Kuma backup from GCS
google.cloud.gcp_storage_object:
action: download
bucket: "{{ gcs_bucket }}"
src: "{{ gcs_object_path }}" # object name in the bucket
dest: "{{ local_temp_backup }}" # the local file path
project: "{{ gcs_project_id }}"
auth_kind: serviceaccount
service_account_contents: "{{ lookup('env', 'GCP_SERVICE_ACCOUNT_CONTENTS') }}"
register: gcs_download
- name: Verify download
ansible.builtin.debug:
msg: "Successfully downloaded {{ gcs_object_path }} to {{ local_temp_backup }}"
when: gcs_download.changed
- name: 2. Restore and Run Uptime Kuma (on Galera Witness VPS)
hosts: galera-witness-hetzner
become: true # Run tasks below as root
vars:
kuma_data_dir: /opt/uptimekuma
db_monitor_hosts: "192.168.8.71 192.168.6.73"
local_temp_backup: "/tmp/kuma.db" # Path on the AWX controller
garb_push_url: "{{ 'http://127.0.0.1:3001/api/push/YOUR_GARB_PUSH_TOKEN'
+ '?status=up&msg=Garb%20service%20is%20up&ping=' }}"
cluster_push_url: "{{ 'http://127.0.0.1:3001/api/push/YOUR_CLUSTER_PUSH_TOKEN'
+ '?status=up&msg=ok&ping=' }}" # The status message will vary and we will modify it later
tasks:
- name: Install System Tools (rsyslog, mariadb-client)
ansible.builtin.apt:
pkg:
- rsyslog
state: present
update_cache: yes
- name: Ensure rsyslog is running
ansible.builtin.service:
name: rsyslog
state: started
enabled: yes
- name: Create Uptime Kuma data directory
ansible.builtin.file:
path: "{{ kuma_data_dir }}"
state: directory
owner: root
group: root
mode: '0755'
- name: Copy backup file from AWX to Witness
ansible.builtin.copy:
src: "{{ local_temp_backup }}"
dest: "{{ kuma_data_dir }}/kuma.db" # This restores the backup
owner: root
group: root
mode: '0644'
- name: Get the host system timezone # Required for fail2ban to work in Docker for uptimekuma
ansible.builtin.command: cat /etc/timezone
register: host_tz_output
changed_when: false # Don't report this as a "change" in the summary
- name: Start Uptime Kuma container
community.docker.docker_container:
name: uptimekuma
image: louislam/uptime-kuma:2 # Version 2.x.x
state: started
pull: true # Always pull it in case there is an updated version
restart: true # Restart the service after the DB is restored
restart_policy: always
ports:
- "3001:3001"
volumes:
# This maps the host dir (with your .db) into the container
- "{{ kuma_data_dir }}:/app/data"
- "/etc/timezone:/etc/timezone:ro" # Sync time with host
- "/etc/localtime:/etc/localtime:ro"
tty: false # Tells app 'you are not in a terminal'
interactive: false # Disables interactive mode
env:
TZ: "{{ host_tz_output.stdout }}"
NO_COLOR: "1" # to avoid binary output in systemd to ensure logs will flow to fail2ban
FORCE_COLOR: "0" # stronger disable for Node.js/Chalk
log_driver: syslog # Avoid using systemd as it cannot process blob data from Node
log_options:
tag: uptimekuma # Tag the logs so Fail2Ban can find them
- name: Create the garb check script
ansible.builtin.copy:
dest: /usr/local/bin/check_garb.sh
mode: '0755'
owner: root
group: root
content: |
#!/bin/bash
# Managed by Ansible - Do Not Edit Manually
PUSH_URL="{{ garb_push_url }}"
SERVICE="garb"
# Check if service is active
if systemctl is-active --quiet "$SERVICE"; then
# Service is UP. Send heartbeat.
# -m 10: Max 10 seconds wait
curl -fsS -m 10 "$PUSH_URL" > /dev/null 2>&1
fi
- name: Add cron job for garb check
ansible.builtin.cron:
name: "Check Galera Arbitrator"
minute: "*/2" # Run every two minutes
job: "/bin/bash /usr/local/bin/check_garb.sh"
user: root
state: present
- name: Create the Cluster Size check script (High Availability) for UptimeKuma
ansible.builtin.copy:
dest: /usr/local/bin/check_cluster_size.sh
mode: '0755'
owner: root
group: root
content: |
#!/bin/bash
# Managed by Ansible
# This script checks multiple nodes for redundancy
HOSTS="{{ db_monitor_hosts }}" # This injects the db_monitor_hosts variable
DB_USER="{{ db_monitor_user }}"
DB_PASS="{{ db_monitor_pass }}"
PUSH_URL_BASE="{{ cluster_push_url | split('?') | first }}"
SIZE=""
# Loop through the hosts
for HOST in $HOSTS; do
# Try to get the size.
# -s: Silent, -N: Skip headers
# --connect-timeout=3: Fail fast (3 seconds) if node is down
TEMP_SIZE=$(mariadb -h $HOST -u "$DB_USER" -p"$DB_PASS" -s -N --connect-timeout=3 -e "SHOW STATUS LIKE 'wsrep_cluster_size';" 2>/dev/null | awk '{print $2}')
if [[ -n "$TEMP_SIZE" ]]; then
SIZE=$TEMP_SIZE
# We got a valid number, stop looking!
break
fi
done
# Check if we got a result from ANY node
if [[ -z "$SIZE" ]]; then
# If we are here, ALL nodes failed to respond
echo "Cluster unreachable"
curl -fsS "$PUSH_URL_BASE?status=down&msg=Connection%20Failed%20(All%20Nodes)&ping=" > /dev/null
exit 1
fi
# Logic: Is size >= 5?
if [ "$SIZE" -ge 5 ]; then
# STATUS OK
curl -fsS "$PUSH_URL_BASE?status=up&msg=Cluster%20Size:%20$SIZE&ping=" > /dev/null
else
# STATUS DEGRADED
curl -fsS "$PUSH_URL_BASE?status=down&msg=Degraded%20Size:%20$SIZE&ping=" > /dev/null
fi
- name: Add cron job for Cluster Size check
ansible.builtin.cron:
name: "Check Galera Cluster Size"
minute: "*/2" # Run every two minutes
job: "/bin/bash /usr/local/bin/check_cluster_size.sh"
user: root
state: present
# Create the Filter for Fail2ban to recognize Uptime Kuma logs
- name: Create Fail2Ban filter for Uptime Kuma
ansible.builtin.copy:
dest: /etc/fail2ban/filter.d/uptimekuma.conf
owner: root
group: root
mode: '0644'
content: |
[Definition]
# Since Node.JS 'colors' the output, it is safer to use these phrases
# You can simulate failed logins and then check: sudo tail -f /var/log/syslog | grep uptimekuma
# Match lines regardless of color codes or prefixes
# We search for "[AUTH]" and the specific error messages
failregex = Incorrect username or password.*IP=<HOST>
Invalid token provided.*IP=<HOST>
Too many failed requests.*IP=<HOST>
ignoreregex =
# Create the jail for Uptime kuma
- name: Create Fail2Ban jail for Uptime Kuma
ansible.builtin.copy:
dest: /etc/fail2ban/jail.d/uptimekuma.local
owner: root
group: root
mode: '0644'
content: |
[uptimekuma]
enabled = true
# Force polling to ensure we catch file updates ('auto' does not work with uptimekuma)
backend = polling
# Read from standard system log where Docker/Rsyslog writes
logpath = /var/log/syslog
# Chain must be DOCKER-USER to block traffic before it reaches the container
chain = DOCKER-USER
port = 3001
protocol = tcp
# Ban Action
action = iptables-allports[name=uptimekuma, chain=DOCKER-USER]
# Ban Policy
maxretry = 4
findtime = 120
bantime = 360
notify: Restart Fail2Ban
handlers:
- name: Restart Fail2Ban
ansible.builtin.service:
name: fail2ban
state: restarted
- name: 3. Clean Up Backup (on AWX)
hosts: localhost
connection: local
gather_facts: no
become: false
tasks:
- name: Remove local backup file from AWX
ansible.builtin.file:
path: "/tmp/kuma.db"
state: absent
localhost (The AWX Execution Environment).become: false (We don't need root to download a file).google.cloud.gcp_storage_object module.
proxmox-backup-bachelor).kuma.db to a temporary location (/tmp/kuma.db) on the AWX runner.Tip: Check the variables! We define the bucket name and object path as variables at the top so they are easy to change later without breaking the logic.
This is where the magic happens on the remote server (galera-witness-hetzner).
Goal: Upload the database, launch the container, and set up a "Dead Man's Switch" for the Galera service.
The Kuma DB Restore part:
/opt/uptimekuma exists with correct permissions.ansible.builtin.copy to move the kuma.db from the AWX controller (local) to the remote VPS.The Container Launch:
community.docker.docker_container module to spin up Uptime Kuma.louislam/uptime-kuma:beta.beta, put a 1.Persistence: We mount the host directory (/opt/uptimekuma) to /app/data inside the container.
Networking: We expose port 3001 so we can access the dashboard via the VPN tunnel.
Time sync: We mount the host's timezone files into the container. This is critical for fail2ban.
We need this node to scream if the critical Galera Arbitrator (garb) service crashes. Since a Docker container cannot easily see host processes, we use a Push Monitor.
garb_push_url: "{{ 'http://127.0.0.1:3001/...' + '...' }}"
check_garb.sh): ansible writes a bash script directly to /usr/local/bin/.
systemctl is-active --quiet garb.SHOW STATUS LIKE 'wsrep_cluster_size'; to see that the output shows the desired number for connected galera nodes? The challenge is that the garb service does not have the required mariadb-client package.*/2) via the root crontab. This ensures we are alerted quickly if the cluster witness goes down.rsyslog and configures Docker to send Uptime Kuma logs to the system syslog. This bypasses binary logging issues with journald.DOCKER-USER iptables chain, effectively blocking traffic before it even reaches the container.mariadb-client to connect to Site 1 or Site 2 databases over the VPN.wsrep_cluster_size. If the size is < 5, it pushes a "Down" signal to Uptime Kuma. If it cannot connect to any node, it reports a connection failure.localhost (not on the VPS) to remove the previously downloaded kuma.db.H3 - Restore Uptimekuma from GCP bucketRunHetzner (the same inventory used by your other jobs)restore-uptimekuma.ymlgoogle.cloud and community.docker).GCP UptimeKuma Backup and Witness DB Monitor.tick the box, as play 2 uses the ‘become: true’ parameter.http://public_ip:3001 but the Uptimekuma push script is not working.# Verify the push URL matches the one in your UptimeKuma instance in your VM (owned by root)
sudo nano /usr/local/bin/check_garb.sh
# Verify that the crontab job is present (owner by root)
sudo nano crontab -e
# If the container is in a restart loop and logs show permission errors,
# ensure the /opt/uptimekuma directory is owned by user 1000 (node):
chown -R 1000:1000 /opt/uptimekuma
# Firstly, see if you can reach the DB
mariadb -h $HOST -u "$DB_USER" -p"$DB_PASS"
exit;
# Run this from your VPS to ensure connection can be established for y our monitoring user:
mariadb -h $HOST -u "$DB_USER" -p"$DB_PASS" -s -N --connect-timeout=3 -e "SHOW STATUS LIKE 'wsrep_cluster_size';" | awk '{print $2}'
# Fill in the variables above and see if you get the desired output in the form of a number.
/opt/uptimekuma content after removing the container./opt/uptimekuma directory is owned by user 1000 (node) using chown -R 1000:1000 /opt/uptimekuma.# Check the uptimekuma logs
sudo docker logs --tail 50 uptimekuma
# Look for lines like these:
2025-11-18T22:30:51Z [DB] INFO: Database Type: sqlite
2025-11-18T22:30:51Z [SERVER] INFO: Connected to the database
2025-11-18T22:31:19Z [DB] ERROR: Database migration failed
2025-11-18T22:31:19Z [SERVER] ERROR: Failed to prepare your database: INSERT INTO "_knex_temp_alter332" SELECT * FROM "stat_hourly"; - SQLITE_CORRUPT: database disk image is malformed
# Fix it by deleting the SQLite files to force a recovery
sudo docker stop uptimekuma
# This is safe to delete when the service is stopped
sudo rm /opt/uptimekuma/kuma.db-wal
sudo rm /opt/uptimekuma/kuma.db-shm
sudo docker start uptimekuma
garbd config.H0 - Workflow - Provision + Configure Galera WitnessExecuted on Hetzner - a VPS is created and set upHetznercloud-init template before proceeding further.http://public_ip:3001. If you would like to go fancy and have it set up with HTTPS, let me know in the comments and I can update the steps. Upon logging in, if not done already, I would recommend you to set up 2FA, since this service is exposed to the world.# Watch the logs as you try logging in with bogus credentials:
sudo tail -f /var/log/syslog | grep uptimekuma
# Once you are jailed, you should be able to see it on the status page:
sudo fail2ban-client status uptimekuma
# Unban your public IP address:
sudo fail2ban-client set uptimekuma unbanip 1.2.3.4
This concludes our rather extensive guide. Hopefully there was something new and interesting in there for you to learn without being overwhelming? In case you are missing some required parts in your infrastructure to make it happen, check out my other Tutorials!
Let me know in the comments below how your journey with Ansible and multi-site deployment of Galera in a hybrid infrastructure environment went 😇