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
I needed a platform that felt modern but stayed out of my way. Woodpecker CI fit the bill because:
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
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
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 <
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.yaml format.
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.example directly 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 <