In this guide, you'll learn to create a fully modular and reusable Terraform solution, deploying resources across Azure, Kubernetes, and Cloudflare.
In my previous article, you learned how to set up a Kubernetes cluster and run Plausible Analytics using a series of CLI commands. While that approach works, it isn't ideal. A better, more sustainable solution is to use Terraform. With Terraform, you describe your infrastructure in its desired state, and Terraform figures out the steps required to get there.
I've also used helmfile before, which is great for managing Helm releases. But what I like about Terraform is that it goes further. It doesn't stop at Kubernetes, it gives you a single solution to define all resources across your stack.
Key Benefits
- Define desired state - No more manual CLI scripts or configuration drift (your environment always represents your code)
- Recoverable - Since you've defined your desired state, it's easy to recover an entire environment with all its settings
- Modular Design - Create modules that can be reused across different environments
- Environment agnostic - Deploy to Azure, Kubernetes, Cloudflare, and many more platforms
- Version control - Track infrastructure changes in version control, maintaining a complete audit trail
Setting up your solution as described in this article extends those benefits with:
- Automatic HTTPS - Let's Encrypt certificates for all your exposed applications
- Cost optimized - Only a single public IP, a cost-effective VM configuration and no extra VM disks
- No configuration drift - infrastructure state centrally stored
- Data safety - Azure disks store your data, enabling easy backup and restore via snapshots or backup-vault
Prerequisites
Ensure the following tools are installed on your machine: Azure CLI, Terraform & Kubernetes tools.
Or alternatively use Cloud Shell in Azure Portal, where all needed tools are pre-installed. When using Cloud Shell, do mount the clouddrive to persist everything across shell sessions: clouddrive mount
& cd clouddrive
.
If you're on Windows, please use Cloud Shell or WSL, as all scripts are written in bash.
Getting Started
There's one small manual step to take before we can dive into Terraform. Our solution needs a backend (storage) for the state file, so we first need to create a Storage Account with a container in Azure. There's a handy script in our Terraform project that does this for you. So let's get our Terraform project first.
You can do this quickly by running the following shell script:
#!/usr/bin/env bash
# Download and extract the terraform project from the repository
curl -L "https://github.com/jeroenbach/bach.software/archive/refs/heads/main.zip" -o "bach.software-terraform.zip"
unzip -q "bach.software-terraform.zip" "bach.software-main/src/app/examples/post4/terraform/*"
# Move the extracted folder to current directory and remove the zip and extracted folder
mv "bach.software-main/src/app/examples/post4/terraform" "./terraform"
rm -rf "bach.software-terraform.zip" "bach.software-main"
# Navigate to the terraform directory
cd "./terraform"
Before running Terraform or any of the next scripts, always make sure you're logged in to the correct Azure subscription using az login
.
Let's create the storage account and container for our Terraform state:
az login
./scripts/create-tfstate-storage.sh
Running Terraform
When running terraform apply
, Terraform will ask for some input variables. You can find the needed values in the input-output.tf file in the same folder.
You can also create a terraform.tfvars file with the needed values, so you don't have to enter them each time.
Note: Make sure not to check in your .tfvars files to version control
azure_subscription_id = "<azure-subscription-id>"
azure_cluster_name = "aks-westeu-prod"
cloudflare_api_token = "<cloudflare-api-token>"
cloudflare_zone_id = "<cloudflare-zone-id>"
plausible_dns = "plausible.example.com"
letsencrypt_email = "[email protected]"
Before starting, make sure you have the required information available. You can create a free Cloudflare account and link it to a DNS you own, or create a new DNS. You can create an API token following these instructions and use the "Edit zone DNS" template. You can find your zone ID following these instructions. You can find your Azure subscription ID following these instructions.
If you don't specify the Cloudflare variables, the DNS won't be updated, but everything else will still work and you'll be shown the IP address (to use to access Plausible) at the end. You do need to create a DNS record with this IP address yourself, as the certificate issuer needs it to validate the DNS record before it can issue a valid certificate.
Now, let's deploy your environment:
# Environment name: Azure Kubernetes Service - Western Europe - Production
cd aks-westeu-prod
terraform init
terraform apply
Terraform will:
- Deploy the AKS cluster
- Install Plausible via Helm
- Update Cloudflare DNS
Backup & Restore (Optional)
In your rg-nodes-aks-westeu-prod resource group, you'll find the two Azure disks that contain all the data of the Plausible solution: pv-disk-plausible-analytics-v2-clickhouse-0 & pv-disk-plausible-analytics-v2-postgresql-0. You can create hourly, daily, or weekly backups of those disks using Azure Backup Vault.
To restore a backup, create a snapshot of the specific backup to a resource group and fill in the snapshot IDs in the following variables found in the aks-westeu-prod/main.tf file: postgresql_restore_snapshot_id and clickhouse_restore_snapshot_id.
Next time you run terraform apply
, Plausible will be restored with the backups.
Destroying the Environment
To destroy the environment and all associated resources, you can run the following command:
terraform destroy
Solution Structure
To make the solution run from beginning to end, there were some hurdles to overcome. In this chapter, I'll examine those hurdles and how I've solved them, and provide a general overview of the solution.
terraform/
├── aks-westeu-prod/
│ ├── input-output.tf
│ ├── main.tf
│ └── provider.tf
├── helm-charts/
│ ├── letsencrypt-cert-issuer/
│ │ ├── templates/
│ │ │ ├── letsencrypt-cluster-issuer-staging.yaml
│ │ │ └── letsencrypt-cluster-issuer.yaml
│ │ ├── Chart.yaml
│ │ └── values.yaml
├── modules/
│ ├── aks-cluster/
│ │ ├── aks-cluster.tf
│ │ ├── ingress-and-certificates.tf
│ │ └── input-output.tf
│ ├── persistent-azure-disk-volume/
│ │ ├── input.tf
│ │ └── persistent-azure-disk-volume.tf
│ └── plausible/
│ │ ├── disks.tf
│ │ ├── input.tf
│ │ ├── namespace.tf
│ │ └── plausible.tf
├── scripts/
│ ├── create-tfstate-storage.sh
│ └── download-terraform-project.sh
aks-westeu-prod
: A production environment configuration for deploying to Azure West Europe. You can use this folder as a template to create more environments.helm-charts
: Custom Helm chartsletsencrypt-cert-issuer
: Instead of deploying the ClusterIssuer resources separately, I packaged them in a Helm chart
modules
: Each module encapsulates a specific responsibilityaks-cluster
: Deploys an AKS cluster with Let's Encrypt certificate issuer, nginx ingress as load balancer, and waits for the public IP to be availablepersistent-azure-disk-volume
: Creates an Azure disk or restores one using a snapshot and then creates a persistent volume and persistent volume claim in Kubernetesplausible
: Installs Plausible and its dependencies via Helm
Connection details of the new cluster not available
After creating the Kubernetes cluster, we want to be able to deploy resources to it. But at the Terraform plan stage, information on how to connect to this new environment is not yet available. Therefore, we had to take two steps to create a seamless deployment.
- Dynamic Provider Configuration: The AKS cluster's information is dynamically set for the Helm and Kubernetes providers by retrieving the connection information from the newly created cluster:
provider "helm" {
kubernetes = {
# Use dynamic provider configuration to use the newly created cluster directly
host = module.aks_cluster.kube_config.host
client_certificate = base64decode(module.aks_cluster.kube_config.client_certificate)
client_key = base64decode(module.aks_cluster.kube_config.client_key)
cluster_ca_certificate = base64decode(module.aks_cluster.kube_config.cluster_ca_certificate)
}
}
provider "kubernetes" {
# Use dynamic provider configuration to use the newly created cluster directly
host = module.aks_cluster.kube_config.host
client_certificate = base64decode(module.aks_cluster.kube_config.client_certificate)
client_key = base64decode(module.aks_cluster.kube_config.client_key)
cluster_ca_certificate = base64decode(module.aks_cluster.kube_config.cluster_ca_certificate)
}
- Set the local kubectl context: After the AKS cluster is created, we write the new kube config and set the kubectl context on the local machine, this way local-exec commands can immediately connect to the new cluster.
resource "null_resource" "set_kube_context" {
provisioner "local-exec" {
command = <<EOT
# We get it from the Terraform state and add it to the kubeconfig
echo '${azurerm_kubernetes_cluster.aks_cluster.kube_config_raw}' > ~/.kube/config
export KUBECONFIG=~/.kube/config
kubectl config use-context ${azurerm_kubernetes_cluster.aks_cluster.name}
EOT
}
// Always set the kube context when running apply, even if no changes were made to the cluster
triggers = {
always_run = "${timestamp()}"
}
depends_on = [azurerm_kubernetes_cluster.aks_cluster]
}
Load Balancer IP not yet available
When deploying a helm release, terraform finishes before the release is completely deployed. It also doesn't provide the load balancer IP information immediately. Therefore I implemented two local scripts that wait for the nginx ingress deployment and collect the load balancer IP, which can be used to update the Cloudflare DNS.
# Wait for the ingress-nginx helm release to be deployed
resource "null_resource" "wait_for_ingress_nginx" {
provisioner "local-exec" {
command = <<EOT
for i in {1..30}; do
kubectl get svc -n ingress-nginx ${helm_release.ingress_nginx.name}-controller && sleep 30 && break || sleep 30;
done
EOT
}
depends_on = [helm_release.ingress_nginx]
}
# Get external IP using kubectl
data "external" "ingress_external_ip" {
program = ["bash", "-c", <<EOT
EXTERNAL_IP=$(kubectl get svc -n ingress-nginx ${helm_release.ingress_nginx.name}-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || echo "")
echo "{\"ip\":\"$EXTERNAL_IP\"}"
EOT
]
depends_on = [null_resource.wait_for_ingress_nginx]
}
The data in our solution is not stored
There's a module available to create or restore an Azure disk and hook it into Kubernetes by creating a persistent volume and persistent volume claim.
This is how you can use the module and link it into your Plausible Helm deployment.
module "create_pv_postgresql" {
source = "../persistent-azure-disk-volume"
snapshot_id = var.postgresql_restore_snapshot_id
azure_location = var.azure_disk_location
pvc_namespace = var.namespace
pv_name = "pv-disk-${var.name}-postgresql-0"
pvc_name = "pvc-disk-${var.name}-postgresql-0"
azure_resource_group_name = var.azure_disk_resource_group_name
disk_size_gb = var.plausible_config_disk_size # Keep this equal to the size defined in the plausible helm chart
depends_on = [kubernetes_namespace.plausible_analytics]
}
# the existingClaim is set to the pvc_name for both postgresql and clickhouse
postgresql:
primary:
persistence:
enabled: true
existingClaim: pvc-disk-${var.name}-postgresql-0
size: ${var.plausible_config_disk_size}Gi # This database is only used for settings and user data, so it doesn't need to be very large
...
clickhouse:
persistence:
enabled: true
existingClaim: pvc-disk-${var.name}-clickhouse-0
size: ${var.plausible_data_disk_size}Gi # This database is used for storing all the analytics data, so it needs to be larger
The Plausible Helm Release is not exposed
When configuring the ingress, we can specify the cert-manager annotation to ensure the certificate is created and ingress information to expose the Plausible service to the internet.
ingress:
enabled: true
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-production"
kubernetes.io/ingress.class: nginx
kubernetes.io/tls-acme: "true"
className: nginx
hosts:
- ${var.plausible_dns}
path: /
pathType: Prefix
tls:
- secretName: letsencrypt-production
hosts:
- ${var.plausible_dns}
Getting the Most Out of Your AKS Environment
In my previous article Ditching the Cookie Banners: Run Plausible Analytics on Azure Kubernetes, you learned a few tricks to reduce the costs of your AKS cluster. These are incorporated in this solution as well.
- Use ephemeral disks: These are stored directly on the VM's local storage and come at no additional cost.
- Standard_B2s configuration: The most cost-effective VM configuration available
- Increase the number of pods per node: To allow more workloads on the Standard_B2s instance
Final Thoughts
We've successfully transformed a set of bash scripts into a production-grade Kubernetes deployment on Azure. By leveraging Terraform's declarative approach and AKS's managed infrastructure, you now have a Plausible Analytics instance that's not just running—it's scalable, maintainable, and ready for real-world traffic.
The beauty of this Infrastructure as Code approach lies in its repeatability. Need a staging environment? Just duplicate the aks-westeu-prod folder with different variables. Want to deploy to another region? Change a single parameter. Every infrastructure decision is documented in code, reviewed through pull requests, and rolled back if needed.
While this setup might seem like overkill for a simple analytics tool, the patterns you've learned here—modularized Terraform, cert-manager integration, proper secret management—will serve you well for any production Kubernetes workload. Start small, iterate often, and let your infrastructure grow with your needs.