DEV Community

Cover image for How I Deployed Woodpecker CI on Fedora IoT
Michael Nikitochkin
Michael Nikitochkin

Posted on

How I Deployed Woodpecker CI on Fedora IoT

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:

  • 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.

System Architecture Diagram

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.

Deployment Flow Diagram

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

  1. I went to GitHub settings > Developer settings > OAuth Apps > New OAuth App7.
  2. Homepage URL: https://ci.homelab.example
  3. Authorization callback URL: https://ci.homelab.example/authorize
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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",
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. I reconfigured PiHole: I forced it to bind only to my LAN and localhost IPs.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!"
Enter fullscreen mode Exit fullscreen mode

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.


  1. Woodpecker CI 

  2. Fedora IoT 

  3. OpenTofu 

  4. Cloudflare Tunnel 

  5. Woodpecker CI Documentation 

  6. Podman Quadlet Guide 

  7. GitHub New OAuth Application 

Top comments (0)