The Workflow: How I post to this Blog

Now that the CI/CD is live, I don't need to touch a web UI or run manual build scripts. Everything is handled through a pure Git workflow.

1. Structure

All my content lives in the content/ directory. Zola uses "Front Matter" (the +++ TOML block) at the start of each file to handle metadata.

2. Creating a Post

To create a post, I just create a new markdown file locally:

touch content/my-new-post.md

I add the headers at the top:

+++
title = "My New Post"
date = 2026-03-25
[taxonomies]
tags = ["idea", "tutorial"]
+++

Your content goes here...

3. Deployment

Once I'm happy with the content, I just push to main. This triggers the Forgejo runner, which pulls the keys from OpenBao and syncs the site to Garage.

git add content/my-new-post.md
git commit -m "content: new post about X"
git push origin main

I can track the build progress at git.jaf.sh/jaf-sh/blog/actions. If the green checkmark appears, the site is live.

Automating blog.jaf.sh: The OpenBao & Garage S3 Pipeline

I finally got tired of manually building my blog and syncing it to S3. I wanted a "push-to-deploy" workflow that didn't involve hardcoding secrets or managing local environment variables. Here is exactly how I built the pipeline.

The Architecture

The goal was simple: push Markdown to Forgejo, and have it appear on blog.jaf.sh. The challenge was doing this securely and natively on my own hardware.

Step 1: Secret Management with OpenBao

I didn't want my S3 keys sitting in plain Forgejo secrets. I used OpenBao with the approle auth method. I created a custom mount and a policy that only allows reading the blog deployment keys.

On the OpenBao side, I configured the role like this:

# Define the policy for restricted access
bao policy write blog-jaf-sh-policy - <<EOF
path "secret/data/blog-jaf-sh" {
  capabilities = ["read"]
}
EOF

# Create the AppRole at a custom mount point
bao write auth/blog-jaf-sh/role/forgejo-runner \
   token_policies="blog-jaf-sh-policy" \
   token_ttl=20m

Step 2: The Forgejo Action

My .forgejo/workflows/deploy.yaml handles the heavy lifting. It uses the vault-action to authenticate against OpenBao.

One specific fix I had to implement was specifying the path: blog-jaf-sh in the action. By default, the action looks for the approle mount, but since I used a custom one, I had to point it to the right place:

- name: Retrieve Credentials from OpenBao
  uses: https://github.com/hashicorp/vault-action@v3
  with:
    url: https://openbao.jaf.sh
    method: approle
    path: blog-jaf-sh
    roleId: ${{ secrets.OPENBAO_ROLE_ID }}
    secretId: ${{ secrets.OPENBAO_SECRET_ID }}
    secrets: |
      secret/data/blog-jaf-sh access_key | S3_ACCESS_KEY ;
      secret/data/blog-jaf-sh secret_key | S3_SECRET_KEY

Step 3: Syncing to Garage S3

Garage is great for self-hosted S3, but I had to make sure rclone was talking to it correctly. Specifically, I had to ensure the REGION was set to garage to match the cluster's signing requirements.

env:
  RCLONE_CONFIG_GARAGE_TYPE: s3
  RCLONE_CONFIG_GARAGE_ENDPOINT: https://s3.jaf.sh
  RCLONE_CONFIG_GARAGE_REGION: garage
run: rclone sync ./public garage:blog.jaf.sh

Now, every time I push to main, the site builds in a clean container and syncs only the changed files. No more manual builds!

Deploying to Garage S3 with rclone

To host blog.jaf.sh on Garage S3, build your site and sync it.

Sync with rclone:

rclone sync ./public/ garage:blog.jaf.sh --progress

Note: Ensure your bucket is configured with garage bucket website --allow blog.jaf.sh.

Building Zola from Source on Fedora

Since Fedora 43 repositories might lag behind, building from source via Rust is the most reliable way.

  1. Install Rust: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
  2. Clone & Build:
    cargo install --locked --git https://github.com/getzola/zola
  3. Verify: zola --version