I wanted a self-hosted CI/CD system that was lightweight and container-native, without the overhead of enterprise solutions. This is the story of how I deployed Woodpecker CI1 on my Fedora IoT2 server. Along the way, I had to navigate DNS conflicts, SELinux hurdles, and the challenge of secure external access.
In this setup, I used OpenTofu3 for infrastructure automation and Cloudflare Tunnel4 to bridge the gap between my local network and the web.
Table of Contents
- Why I Chose Woodpecker CI
- My Infrastructure Layout
- The Road to Deployment
- Step 1: Handing Security & Secrets
- Step 2: Bridging with Cloudflare Tunnel
- Step 3: Orchestrating the Server
- Step 4: Taming the Agent & Networking
- How I Verified Everything
- Final Thoughts & Troubleshooting
Why I Chose Woodpecker CI
I needed a platform that felt modern but stayed out of my way. Woodpecker CI fit the bill because:
- Isolation: I liked that every build runs in its own ephemeral container.
- Integration: It has seamless support for the GitHub OAuth flow I already use.
-
Simplicity: I could define my pipelines in a familiar
.woodpecker.yamlformat.
Researching the bits: I spent some time with the Woodpecker architecture docs5 to understand how the server and agent communicate over gRPC.
My Infrastructure Layout
I decided to use Cloudflare Tunnel so I wouldn't have to touch my firewall or handle SSL certificates manually. My Woodpecker Server acts as the brain, while the Agent does the heavy lifting via the Podman socket.
My Core Decisions
- DNS: I solved the port 53 conflict by using a custom bridge network for internal resolution.
-
Access: I mapped
https://ci.homelab.exampledirectly to my local instance.
The Road to Deployment
My process involved four main phases. I used OpenTofu to make the deployment repeatable and Podman Quadlets6 to manage the container lifecycles as systemd services.
Step 1: Handing Security & Secrets
I started with the security groundwork. First, I set up a new OAuth application on GitHub to handle authentication.
My GitHub OAuth Setup
- I went to GitHub settings > Developer settings > OAuth Apps > New OAuth App7.
-
Homepage URL:
https://ci.homelab.example -
Authorization callback URL:
https://ci.homelab.example/authorize - I made sure to store the Client ID and Client Secret securely for later.
Generating the Agent Secret
The server and agent need a shared secret to talk to each other. I generated a secure random string using openssl:
openssl rand -hex 32
Step 2: Bridging with Cloudflare Tunnel
I chose to use a tunnel because it's much simpler than managing port forwarding on my router.
My Initial Authentication
I had to run this once on my Fedora IoT server to link it to my Cloudflare account:
cloudflared tunnel login
Automation with OpenTofu
I wrote an OpenTofu resource to automate the installation of cloudflared, create the tunnel, and set up the systemd service. Here is the configuration I used:
resource "null_resource" "setup_cloudflare_tunnel" {
connection {
type = "ssh"
user = "admin"
host = "192.168.1.100"
private_key = file("~/.ssh/id_ed25519")
}
provisioner "file" {
content = local.tunnel_script_content
destination = "/tmp/setup_cloudflare_tunnel.sh"
}
provisioner "remote-exec" {
inline = [
"chmod +x /tmp/setup_cloudflare_tunnel.sh",
"/tmp/setup_cloudflare_tunnel.sh",
]
}
}
locals {
tunnel_script_content = <<-EOF
#!/bin/bash
set -euo pipefail
# Fedora IoT uses rpm-ostree, install cloudflared from binary if missing
if ! command -v cloudflared &> /dev/null; then
ARCH=$(uname -m)
case $ARCH in
x86_64) DOWNLOAD_URL="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64" ;;
aarch64) DOWNLOAD_URL="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64" ;;
*) echo "❌ Unsupported architecture: $ARCH"; exit 1 ;;
esac
curl -L "$DOWNLOAD_URL" -o /tmp/cloudflared
sudo install -m 755 /tmp/cloudflared /usr/local/bin/cloudflared
fi
# Create tunnel and route DNS (requires manual 'cloudflared tunnel login' first)
TUNNEL_NAME="woodpecker"
cloudflared tunnel create "$TUNNEL_NAME" || true
TUNNEL_ID=$(cloudflared tunnel list | grep "$TUNNEL_NAME" | awk '{print $1}')
cloudflared tunnel route dns "$TUNNEL_NAME" ci.homelab.example 2>/dev/null || true
# Setup system user and config
sudo useradd --system --home /var/lib/cloudflared --shell /usr/sbin/nologin cloudflared 2>/dev/null || true
sudo mkdir -p /etc/cloudflared /var/lib/cloudflared
sudo cp "$HOME/.cloudflared/$TUNNEL_ID.json" /etc/cloudflared/
sudo chown -R cloudflared:cloudflared /etc/cloudflared /var/lib/cloudflared
sudo tee /etc/cloudflared/config.yml > /dev/null <<CONFIG
tunnel: $TUNNEL_ID
credentials-file: /etc/cloudflared/$TUNNEL_ID.json
ingress:
- hostname: ci.homelab.example
service: http://localhost:8000
- service: http_status:404
CONFIG
# Setup and start Systemd service
sudo tee /etc/systemd/system/cloudflared.service > /dev/null <<SERVICE
[Unit]
Description=Cloudflare Tunnel
After=network.target
[Service]
Type=simple
User=cloudflared
Group=cloudflared
ExecStart=/usr/local/bin/cloudflared tunnel --config /etc/cloudflared/config.yml run
Restart=on-failure
[Install]
WantedBy=multi-user.target
SERVICE
sudo systemctl daemon-reload
sudo systemctl enable --now cloudflared
EOF
}
My shell script (which I managed in a local block) handles the heavy lifting of installing the binary and configuring the cloudflared system user.
Step 3: Orchestrating the Server
For the Woodpecker Server, I used a Podman Quadlet6 container. This allowed me to manage the container-native service directly through systemd.
My Quadlet Configuration (woodpecker-server.container)
[Container]
ContainerName=woodpecker-server
Image=docker.io/woodpeckerci/woodpecker-server:v3
PublishPort=8000:8000
PublishPort=9000:9000
Volume=/var/lib/woodpecker/server:/var/lib/woodpecker:Z
Environment=WOODPECKER_ADMIN=admin
Environment=WOODPECKER_HOST=https://ci.homelab.example
Environment=WOODPECKER_GITHUB=true
Environment=WOODPECKER_GITHUB_CLIENT=Iv1.a629723b814c123e
Environment=WOODPECKER_GITHUB_SECRET=ghs_1234567890abcdef1234567890abcdef12345678
Environment=WOODPECKER_AGENT_SECRET=a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456
[Service]
Restart=always
[Install]
WantedBy=multi-user.target
My Automation Flow
I used OpenTofu to push the container file and refresh the systemd daemon. This made it easy to iterate on my configuration.
resource "null_resource" "woodpecker_server" {
# ... connection details ...
provisioner "file" {
content = local.woodpecker_server_config
destination = "/etc/containers/systemd/woodpecker-server.container"
}
provisioner "remote-exec" {
inline = [
"sudo mkdir -p /var/lib/woodpecker/server",
"sudo chown $USER:$USER -R /var/lib/woodpecker",
"sudo systemctl daemon-reload",
"sudo systemctl enable --now woodpecker-server.service",
]
}
}
Step 4: Taming the Agent & Networking
This was the trickiest part of my journey. I had to deal with SELinux and a annoying DNS conflict.
My DNS Port 53 Conflict
Since I run PiHole on the same machine, it was listening on 0.0.0.0:53. This completely blocked Podman from starting its own internal DNS service for my build networks.
The symptoms I saw:
- My agent containers refused to start.
- I found
"Address already in use (os error 98)"in the logs.
How I fixed it:
- I reconfigured PiHole: I forced it to bind only to my LAN and localhost IPs.
- I disabled Podman DNS: I made a global change to Podman's config so it wouldn't try to claim port 53.
My Podman Fix (/etc/containers/containers.conf)
[network]
dns_enabled = false
My Agent Configuration
I also had to disable SELinux labels for the agent so it could talk to the Podman socket without being blocked.
[Container]
ContainerName=woodpecker-agent
Image=docker.io/woodpeckerci/woodpecker-agent:v3
User=root
SecurityLabelDisable=true
Volume=/run/podman/podman.sock:/var/run/docker.sock
Volume=/etc/containers:/etc/containers:ro
Environment=WOODPECKER_SERVER=192.168.1.100:9000
Environment=WOODPECKER_AGENT_SECRET=a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456
Environment=WOODPECKER_BACKEND=docker
How I Verified Everything
Once the services were up, I ran a quick status check and set up my first pipeline.
1. Verification
I checked that my three core services were healthy:
sudo systemctl status cloudflared woodpecker-server woodpecker-agent
2. My First Pipeline
I logged into my new dashboard at https://ci.homelab.example and added this .woodpecker.yaml to one of my repos:
steps:
- name: test
image: alpine:latest
commands:
- echo "Hello from Woodpecker CI!"
Watching the first green checkmark appear was a great feeling.
Final Thoughts & Troubleshooting
The biggest hurdle for me was definitely the DNS conflict. If you find your builds can't resolve hostnames, check if Podman is fighting another service for port 53.
I'm really happy with how this turned out. It's a clean, efficient CI/CD setup that runs perfectly on my Fedora IoT hardware.


Top comments (0)