Introduction

Today I modernized my Hugo blog deployment pipeline by replacing a Rundeck webhook with a fully automated Gitea Actions workflow using ansible-pull. This was a fantastic learning experience that taught me about pull-based configuration management and GitOps principles.

The Old Way: Rundeck Webhook

Previously, pushing to the main branch would trigger a Rundeck webhook that orchestrated the entire build and deployment process. While this worked, it had some drawbacks:

  • External dependency on Rundeck
  • Less visibility into the deployment process
  • Configuration scattered between Git and Rundeck

The New Way: Gitea Actions + ansible-pull

The new pipeline is fully declarative and version-controlled:

Push to main → Gitea Actions → Build → Trigger ansible-pull → Deploy to Swarm

Architecture Overview

Step 1: Build Phase (Gitea Actions)

  • Checkout repository with submodules (PaperMod theme)
  • Build Hugo static site
  • Build Docker image with Nginx
  • Push to local Docker registry

Step 2: Deployment Phase (ansible-pull)

  • SSH to Docker Swarm manager
  • Trigger ansible-pull command
  • Swarm manager pulls the repository
  • Executes Ansible playbook against itself
  • Deploys Docker stack

What is ansible-pull?

This was my first deep dive into ansible-pull, and it’s a clever inversion of traditional Ansible:

Traditional Ansible (Push):

Control Node → SSH → Target Servers

ansible-pull (Pull):

Target Server → Git Pull → Self-Configure

Key Advantages

  1. Decentralized: Each node manages itself
  2. GitOps: Git repository is the source of truth
  3. Simpler CI/CD: CI only needs to trigger, not orchestrate
  4. Self-healing: Can run on a schedule to maintain desired state
  5. Secure: No persistent SSH connections needed

Implementation Details

The Workflow

The Gitea Actions workflow (.github/workflows/deploy.yml) does:

  1. Builds the Hugo site and Docker image
  2. Pushes image to local registry (registry.gravyflex.ca:5000)
  3. SSHs to swarm manager and runs:
ansible-pull \
  -U [email protected]:Aldrich/blog-hugo.git \
  -C main \
  -i localhost, \
  -d /var/lib/ansible/pull/blog-hugo \
  ansible_main.yml

The Playbook

The ansible_main.yml playbook runs locally on the swarm manager:

- hosts: all
  name: Deploy Hugo blog to Docker Swarm
  become: true
  connection: local

  tasks:
    - Create NFS Docker volume
    - Set permissions
    - Copy docker-compose.yml from repo
    - Copy Nginx configuration
    - Deploy Docker stack

Troubleshooting Journey

The path to success had some learning moments:

Issue 1: GitHub Registry Authentication

Error: username is empty

Problem: The build script tried to authenticate with GitHub Container Registry, but my local registry doesn’t need auth.

Fix: Removed GH_TOKEN and GH_USER from the workflow and build script.

Issue 2: Ansible Not Installed

Error: ansible-pull: command not found

Problem: Forgot the one-time setup on the swarm manager.

Fix:

sudo apt install ansible -y
ansible-galaxy collection install community.docker

Issue 3: Permission Denied

Error: could not create leading directories

Problem: Working directory didn’t exist with correct permissions.

Fix:

sudo mkdir -p /var/lib/ansible/pull/blog-hugo
sudo chown -R gravyflex:gravyflex /var/lib/ansible/pull/blog-hugo

Required Secrets

The workflow only needs 4 Gitea secrets:

Secret Purpose
SSH_PRIVATE_KEY SSH access to swarm manager
SWARM_MANAGER_HOST Swarm manager hostname/IP
SWARM_MANAGER_USER SSH username
REPO_URL Git repository URL for ansible-pull

Benefits Realized

Infrastructure as Code: Deployment logic is versioned in Git

Declarative: The playbook describes desired state, not steps

Idempotent: Can re-run safely anytime

Auditable: Git history shows all deployment changes

Self-documenting: The playbook IS the documentation

Simple CI: Gitea Actions just builds and triggers, doesn’t orchestrate

What I Learned

  1. ansible-pull is powerful: Perfect for self-configuring systems and GitOps workflows

  2. Pull > Push for some use cases: When targets can reach Git but control plane can’t always reach targets

  3. Separation of concerns: CI builds artifacts, deployment configs live in Git, targets self-configure

  4. Git as source of truth: Deployment state is versioned and auditable

  5. Debugging is learning: Each error taught me something about the system

Future Improvements

Some ideas for enhancement:

  • Add health checks after deployment
  • Implement blue-green deployment strategy
  • Add Ansible Vault for sensitive variables
  • Set up scheduled ansible-pull runs for self-healing
  • Add deployment notifications (Slack/Discord/Apprise)

Conclusion

This was an incredibly rewarding day of learning. Moving from a webhook-based deployment to a GitOps-style ansible-pull workflow has made the deployment process more transparent, maintainable, and version-controlled.

The beauty of ansible-pull is its simplicity: targets pull their configuration from Git and converge to the desired state. No complex orchestration needed.

Big thanks to Claude Code for the detailed explanations and patient debugging! 🤖

Resources


This blog post was deployed using the exact workflow described above!