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
- Decentralized: Each node manages itself
- GitOps: Git repository is the source of truth
- Simpler CI/CD: CI only needs to trigger, not orchestrate
- Self-healing: Can run on a schedule to maintain desired state
- Secure: No persistent SSH connections needed
Implementation Details
The Workflow
The Gitea Actions workflow (.github/workflows/deploy.yml
) does:
- Builds the Hugo site and Docker image
- Pushes image to local registry (
registry.gravyflex.ca:5000
) - 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
-
ansible-pull is powerful: Perfect for self-configuring systems and GitOps workflows
-
Pull > Push for some use cases: When targets can reach Git but control plane can’t always reach targets
-
Separation of concerns: CI builds artifacts, deployment configs live in Git, targets self-configure
-
Git as source of truth: Deployment state is versioned and auditable
-
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!