Simple Astro blog on Bunny.net, with Terraform and buddy.works CI
In the past, I always used WordPress to host my personal blog on this domain. And it worked fine, but it was overkill for the simple content I wanted to publish, and I wanted to have more control over the content and the way it is published. So I decided to switch to a static site generator, and I chose Astro for that. It is a great tool for building static sites, and it has a lot of features that make it easy to use.
I decided to go with European solutions for infrastructure, as much as possible, and I chose Bunny.net for that. Additionally, to store Terraform state, I use Scaleway Object Storage and Buddy.Works for CI.
In the versions.tf file, I specify the required Terraform version and the provider to Bunny.net. You can notice the skipping of validations for the backend. The reason behind it is that I store Terraform state in the Scaleway Object Storage, not AWS S3. However, both are compatible and Scaleway does not have their own backend to use.
Terraform
terraform {
required_version = ">= 1.14"
required_providers {
bunnynet = {
source = "BunnyWay/bunnynet"
version = "~> 0.13.0"
}
}
backend "s3" {
key = "terraform.tfstate"
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
}
}
provider "bunnynet" {
api_key = var.bunny_api_key
}
variables.tf contains all the variables that are used in the Terraform code. I think descriptions are enough, so I won’t go into details here.
variable "bunny_api_key" {
description = "Bunny.net API key"
type = string
sensitive = true
}
variable "project_name" {
description = "Project name"
type = string
}
variable "custom_hostname" {
description = "Custom hostname for the website"
type = string
}
variable "storage_region" {
description = "Storage region for Bunny.net storage zone"
type = string
default = "DE"
}
variable "monthly_bandwidth_limit_gb" {
description = "Monthly bandwidth limit for Bunny.net storage zone in GB"
type = number
default = 50
}
In outputs.tf I added a few outputs, I don’t use them in the code anywhere yet, but they are useful for debugging.
output "cdn_domain" {
description = "The CDN domain for the pull zone"
value = bunnynet_pullzone.blog.cdn_domain
}
output "storage_hostname" {
description = "The hostname for the storage zone"
value = bunnynet_storage_zone.blog.hostname
}
output "storage_zone_name" {
description = "The name of the storage zone"
value = bunnynet_storage_zone.blog.name
}
output "storage_password" {
description = "The password for the storage zone"
value = bunnynet_storage_zone.blog.password
sensitive = true
}
output "pullzone_id" {
description = "The ID of the pull zone"
value = bunnynet_storage_zone.blog.id
}
output "custom_hostname_ssl_status" {
description = "Whether SSL is enabled for the custom hostname"
value = bunnynet_pullzone_hostname.blog.tls_enabled
}
And now last but not least, I added the main.tf file. It contains the Terraform code for storage, pull zone, custom hostname and security policy.
resource "bunnynet_storage_zone" "blog" {
name = var.project_name
region = var.storage_region
zone_tier = "Standard"
replication_regions = ["UK"] // I want to replicate to UK for redundancy
}
resource "bunnynet_pullzone" "blog" {
name = var.project_name
origin {
type = "StorageZone"
storagezone = bunnynet_storage_zone.blog.id
}
routing {
tier = "Standard"
zones = ["EU", "US"] // I want to route to EU and US for redundancy
}
cache_expiration_time = 2592000
cache_expiration_time_browser = 86400
cache_errors = false // I don't want to cache error responses
cache_stale = ["updating"] // I want to prioritize serving of content, while updating cache with fresh content
sort_querystring = true // Actually I don't expect to have a lot of query parameters, but I want to make sure that they are sorted to optimize caching
limit_bandwidth = var.monthly_bandwidth_limit_gb * 1024 * 1024 * 1024 // Limit bandwidth to the specified monthly limit in GB
block_post_requests = true // Securing the pull zone by blocking POST requests
block_no_referer = false // Allow requests from clients that don't send referer header, as it is common for some browsers, and I don't want to block them from accessing the content
}
resource "bunnynet_pullzone_hostname" "blog" {
name = var.custom_hostname
pullzone = bunnynet_pullzone.blog.id
tls_enabled = true // Enable TLS for the blog
force_ssl = true // Force SSL connection for the blog
}
resource "bunnynet_pullzone_shield" "blog_shield" {
pullzone = bunnynet_pullzone.blog.id
ddos {
level = "Medium" // Medium DDoS protection
}
waf {
enabled = true // Enable WAF rules to protect the blog
allowed_http_methods = ["GET", "HEAD"] // Allow only GET and HEAD requests to additionally protect the blog from malicious requests
}
}
Buddy Works CI
I use Buddy Works for CI, and it’s maybe not so simple to set up, as there is no much ready-to-use documentation, especially for other less popular services like Bunny.net. But almost everything I do afterhours is for fun, and it was a great adventure to make it work.
I decided to divide my CI into two pipelines: One for Pull Requests to just validate the Terraform plan and the second for the main branch to deploy the blog.
Below is the buddy.yaml file for both pipelines.
- pipeline: "Terraform Plan"
events:
- type: "PUSH"
refs:
- "refs/pull/*"
actions:
- action: "terraform init & plan"
type: "BUILD"
docker_image_name: "hashicorp/terraform"
docker_image_tag: "1.14.7"
working_directory: "/buddy/michakme/tf" # Path in Buddy Works is usually in format /buddy/repository_name
reset_entrypoint: true # This is needed to reset the entrypoint to the default one, to execute the commands in the working directory
execute_commands: # All variables are set in Buddy Works as environment variables, and they are injected into the build environment during execution
- terraform init
-backend-config="bucket=$TF_BACKEND_CONFIG_BUCKET"
-backend-config="region=$TF_BACKEND_CONFIG_REGION"
-backend-config="endpoints={s3=\"$TF_BACKEND_CONFIG_ENDPOINT\"}"
-backend-config="access_key=$SCW_ACCESS_KEY"
-backend-config="secret_key=$SCW_ACCESS_SECRET"
- terraform plan
- pipeline: "Plan, Build and Deploy"
events:
- type: "PUSH"
refs:
- "refs/heads/main"
actions:
- action: "terraform init & plan"
type: "BUILD"
docker_image_name: "hashicorp/terraform"
docker_image_tag: "1.14.7"
working_directory: "/buddy/michakme/tf"
reset_entrypoint: true
execute_commands:
- terraform init
-backend-config="bucket=$TF_BACKEND_CONFIG_BUCKET"
-backend-config="region=$TF_BACKEND_CONFIG_REGION"
-backend-config="endpoints={s3=\"$TF_BACKEND_CONFIG_ENDPOINT\"}"
-backend-config="access_key=$SCW_ACCESS_KEY"
-backend-config="secret_key=$SCW_ACCESS_SECRET"
- terraform plan
- action: "wait for plan approval"
type: "WAIT_FOR_APPLY" # This action waits for approval from the user before proceeding to the next action
trigger_time: "ON_EVERY_EXECUTION"
comment: "Do you want to apply the plan?"
- action: "terraform apply"
type: "BUILD"
docker_image_name: "hashicorp/terraform"
docker_image_tag: "1.14.7"
working_directory: "/buddy/michakme/tf"
reset_entrypoint: true
execute_commands:
- terraform apply -auto-approve
- action: "install dependencies and build"
type: "BUILD"
docker_image_name: "node"
docker_image_tag: "25-alpine"
working_directory: "/buddy/michakme"
execute_commands:
- npm install -g pnpm@latest-10 # In the Node.js 25 image there is no more corepack that was usually recommended to install pnpm
- pnpm install
- pnpm run build
- action: "wait for deploy approval"
type: "WAIT_FOR_APPLY"
trigger_time: "ON_EVERY_EXECUTION"
comment: "Do you want to deploy?"
- action: "deploy"
type: "TRANSFER" # Deploy to Bunny.net is possible through FRP or API, in this case I decided to use the FTP transfer
input_type: "BUILD_ARTIFACTS" # The build artifacts we built in the previous step
local_path: "/dist" # In the transfer local path is relative to the /boddy/repository_name, so in this case it is /buddy/michakme/dist
remote_path: "/" # This is the path in the Bunny.net storage, usually it is /
deployment_excludes: # I do not expect to have any sensitive files in the repository, but I want to make sure that even if they are there, they are not deployed to the Bunny.net storage, so I exclude them from the deployment
- "buddy.yaml"
- ".git"
- ".env*"
targets:
- target: "production"
type: "FTP"
secure: false # Bunny.net FTP server does not support SSL,
host: $FTP_HOST
port: $FTP_PORT
auth:
username: $FTP_USERNAME
password: $FTP_PASSWORD
- action: "purge cache" # Purge the cache after deployment to make sure that the new content is served immediately. This is done with the Bunny.net API call
type: "HTTP"
notification_url: "https://api.bunny.net/pullzone/$PULLZONE_ID/purgeCache"
method_url: "POST"
headers:
- name: "AccessKey"
value: $TF_VAR_bunny_api_key
- name: "Content-Type"
value: "application/json"
And that’s all folks! I hope this blog post will be useful for someone who wants to set up a similar infrastructure for their blog or website. If you have any questions or suggestions, feel free to reach out to me on Mastodon or GitHub.