This post is a mix between a technical post, an announcement and a test (for me). I am experimenting with moving this website from a VPS to Bunny CDN, as part of a bigger initiative to simplify my digital footprint.
What I am going to talk about is how to host a static website, specifically a zola site, using Bunny CDN.
Introduction
Before discussing the technical setup for the benefit of those who might want to do the same, I want to add that nothing changes from a privacy or data collection point of view. In fact, Bunny CDN exposes a feature to hide (last octet) or disable (everything to 0.0.0.0) the IP address of those visiting this site for GDPR compliance. Since I am not interested in analytics, I am using this option, which means that no personal data is even accidentally collected.
Static sites are quite great, because they are very easy to host. Many people use AWS S3 for this purpose, but (and this is a topic that deserves its own blog post) just in the last weeks we have seen a big push to stop relying on US for everything digital. This can therefore be a reasonable alternative for those looking for a simple and cheap setup that doesn’t rely on AWS or on GitHub (pages), as Bunny CDN is a Slovenian company.
The setup will overall include:
- A Bunny pull zone
- A Bunny storage zone
- Some gitea action that builds the site
- Some small code that uploads files to Bunny storage zone
Bunny zones
The terminology is still quite strange to me, but essentially a storage zone is a bucket in which files are stored and where they can be retrieved. Pretty easy. A “pull” zone is what I would generally call a frontend, the user-facing part of the website that users interact with, where caches or geo-blocking can be configured.
The cost of this setup is $0.01/GB for the pull zone and #0.02/GB for the storage zone (replicated in 2 sites). If my calculations are right, this site should - worst case scenario - cost me cents to host.
Pull zones can be hooked to origin servers (URLs) or storage zones, so you can see that the idea of the setup is to upload our static files to the storage zone, and then point the pull zone to the storage zone. That’s it.
Pull zones can be configured hostnames and Bunny integrates directly with Let’s Encrypt for SSL certificates, so that’s also pretty straightforward. I won’t duplicate their documentation here, but essentially this and this was all I needed.
Once the Bunny side is sorted out (because it’s this easy), all I needed was a reliable way to get my static content into the storage zone after every update.
Gitea Action
I decided to automate the setup using Gitea actions, since I am versioning this website in Git in my own gitea instance.
The action is pretty straightforward, it checks out the code of the repository, installs zola, builds the site and uploads the content.
name: ci
on:
push:
branches:
- 'master'
jobs:
build-and-upload:
name: Build site and upload to BunnyCDN
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: taiki-e/install-action@v2
with:
tool: zola@0.19.1
- name: Build with Zola
run: zola build
- name: Run BunnyCDN Uploader
run: ./deploy/bunnyUpload -z loudwhisper -k ${{ secrets.BUNNY_API_KEY }} -f ./public
- name: Notify success
if: success()
run: echo "Build and upload to BunnyCDN completed successfully"
You will see that the upload is done with a bunnyUpload
command. This is a
simple binary that I have written.
use std::fs;
use std::path::Path;
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderValue};
use clap::{Arg, Command};
fn main() {
// Set up command-line interface using Clap
let matches = Command::new("BunnyCDN Uploader")
.version("1.0")
.about("Uploads a local folder to BunnyCDN storage")
.arg(Arg::new("storage_zone")
.short('z')
.long("zone")
.value_name("STORAGE_ZONE")
.help("BunnyCDN storage zone name")
.required(true))
.arg(Arg::new("api_key")
.short('k')
.long("key")
.value_name("API_KEY")
.help("BunnyCDN API key")
.required(true))
.arg(Arg::new("local_folder")
.short('f')
.long("folder")
.value_name("LOCAL_FOLDER")
.help("Local folder to upload")
.required(true))
.arg(Arg::new("remote_dir")
.short('r')
.long("remote")
.value_name("REMOTE_DIR")
.help("Remote directory path (optional)")
.default_value(""))
.get_matches();
// Extract values from command-line arguments
let storage_zone = matches.get_one::<String>("storage_zone").unwrap();
let api_key = matches.get_one::<String>("api_key").unwrap();
let local_folder_path = matches.get_one::<String>("local_folder").unwrap();
let remote_dir = matches.get_one::<String>("remote_dir").unwrap();
// Canonicalize the local folder path
let local_folder = match fs::canonicalize(local_folder_path) {
Ok(path) => path,
Err(_) => {
eprintln!("Error: Invalid local folder '{}'", local_folder_path);
return;
}
};
let client = Client::new();
println!("Starting upload to BunnyCDN storage zone: {}", storage_zone);
println!("Local folder: {}", local_folder.display());
println!("Remote directory: {}", if remote_dir.is_empty() { "root" } else { remote_dir });
upload_directory(
&local_folder,
&local_folder,
remote_dir,
storage_zone,
api_key,
&client,
);
println!("Upload completed!");
}
fn upload_directory(
base_dir: &Path,
current_dir: &Path,
remote_dir: &str,
storage_zone: &str,
api_key: &str,
client: &Client,
) {
if let Ok(entries) = fs::read_dir(current_dir) {
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if path.is_dir() {
upload_directory(base_dir, &path, remote_dir, storage_zone, api_key, client);
} else if path.is_file() {
if let Ok(relative_path) = path.strip_prefix(base_dir) {
let remote_path = if remote_dir.is_empty() {
relative_path.to_string_lossy().to_string()
} else {
format!("{}/{}", remote_dir, relative_path.to_string_lossy())
};
println!("Uploading: {} to {}", path.display(), remote_path);
if let Ok(file_content) = fs::read(&path) {
let mut headers = HeaderMap::new();
if let Ok(value) = HeaderValue::from_str(api_key) {
headers.insert("AccessKey", value);
}
let url = format!("https://storage.bunnycdn.com/{}/{}", storage_zone, remote_path);
match client.put(&url).headers(headers).body(file_content).send() {
Ok(response) => {
let status = response.status().as_u16();
if status == 201 {
println!("Successfully uploaded: {}", remote_path);
} else {
println!("Failed to upload: {} (HTTP Status: {})", remote_path, status);
}
},
Err(e) => println!("Failed to upload {}: {}", remote_path, e),
}
}
}
}
}
}
}
To avoid having to deal with OpenSSL and GLIBC dependencies, I used the
following cargo
configuration:
[package]
name = "bunnyUpload"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "rustls-tls"] }
clap = "4.5.32"
And then I build the binary with:
RUSTFLAGS='-C link-arg=-s' cargo build --release --target x86_64-unknown-linux-musl
Conclusion
This setup is still in testing, in fact I want to see how this post being shared on Mastodon (which notoriously causes a wave of request for every federated instances) will impact the traffic.
However, I am happy so far as this simplifies a bit the setup and also doesn’t require a dedicated machine/IP. I feel like I am now making use of all the benefit of using a static site.
There are still things to improve, for example the upload script will never delete things. I currently don’t see myself deleting anything, and if I do it will be an exception, which means I can probably just delete the file manually from the storage if I really want to avoid it being available on the internet. However, different people have different needs, so consider this. Bunny is working on S3 API support, which will make this even easier in the future!
If you find an error, want to propose a correction, or you simply have any kind of comment and observation, feel free to reach out via email or via Mastodon.