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!