Containers

Woodpecker CI with Forgejo: Docker Compose Install and Pipelines

You have a Forgejo instance and a repository that should test itself on every push, and you want the CI outside the forge, not bolted into it. Woodpecker CI with Forgejo does exactly that: Woodpecker logs you in through a Forgejo OAuth2 app, Forgejo fires a webhook on each push, and a Woodpecker agent runs every step as its own Docker container. When the pipeline finishes, the commit in Forgejo turns green or red.

Original content from computingforgeeks.com - post 182148

This guide wires the two together with Docker Compose, starting with the OAuth app and the exact server and agent compose file we ran. From there it moves to a first .woodpecker.yaml, a matrix build with a secret, the new per-organization agent restriction and the envsubst regression that makes the version pin matter. I confirmed the setup working on Debian 13 in November 2026 with Woodpecker 3.20.1 and Forgejo 17.0.1, and every screen and log below comes from that lab host.

Map the handshake between Forgejo and Woodpecker

Three conversations hold this setup together, and every error you’ll hit later breaks one of them. Forgejo is the identity provider, so you sign in to Woodpecker with your Forgejo account. Forgejo is also the event source, sending a webhook to /api/hook on every push. The agent is a worker that dials the server over gRPC on port 9000 and asks for jobs, and it never talks to Forgejo except to clone code inside a pipeline.

Woodpecker CI and Forgejo architecture diagram: git push to Forgejo on port 3000, webhook POST /api/hook to woodpecker-server on 8000, woodpecker-agent connecting over gRPC 9000

Everything in the diagram ran on one Debian VM called forge01, at 192.168.1.158 on the LAN. Forgejo listens on 3000 and the Woodpecker UI on 8000, while 9000 stays inside the Docker network because the agent sits in the same compose project. Replace 192.168.1.158 with your Docker host’s address wherever it appears below, since the same address ends up in the Forgejo root URL, the OAuth redirect and the webhook.

Choose Woodpecker or Forgejo Actions

Forgejo already ships its own CI, so the honest first question is whether you need Woodpecker at all. If your workflows come from GitHub and you want the same YAML shape, Forgejo Actions with a forgejo-runner is the shorter path. Woodpecker earns its place when you want a small, separate CI that doesn’t care which forge it serves, with a pipeline syntax you can read in one sitting. The table puts the two side by side.

Woodpecker CIForgejo Actions
Where it runsSeparate server plus agents, signs in through an OAuth2 appBuilt into Forgejo, jobs go to a separate forgejo-runner
Pipeline file.woodpecker.yaml or a .woodpecker/ folder.forgejo/workflows/, falling back to .github/workflows/
SyntaxIts own, built on steps, image, commands, when and matrixGitHub Actions style, described by Forgejo as familiar, not compatible
ForgesForgejo, Gitea, GitHub, GitLab, Bitbucket and othersForgejo only
Step isolationEvery step is a container imageDocker or LXC jobs that can reuse published actions

We would pick Woodpecker for a team that runs plain shell steps in containers and wants its CI to survive a forge migration, because the pipeline file never mentions Forgejo. Actions suits a team that already writes GitHub-style workflows and wants one less service to run. The two read different directories, so nothing stops you from trying both against the same Forgejo before you decide.

Prepare the Docker host

The lab host was a Debian 13 VM with 4 vCPUs, 8 GB of RAM and a 40 GB disk. That is more than this stack needs at rest, because only the Go builds inside pipelines really use the CPU, so size the box for your heaviest build rather than for Woodpecker itself. The host has to provide four things.

  1. Docker Engine with the Compose plugin (the lab ran Docker 29.9.2 and Compose v5.6.1 from get.docker.com).
  2. A user in the docker group, so the compose commands below run without sudo.
  3. Ports 3000 (Forgejo) and 8000 (Woodpecker) reachable from your browser, plus 443 later if you add the Caddy front end.
  4. A Forgejo account that owns or can admin the repositories you want to build.

If Docker is not on the box yet, the convenience script is the fastest way onto a fresh Debian or Ubuntu server, and our Docker cheat sheet covers the day-two commands. Install it and add your user to the group.

curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
sudo sh /tmp/get-docker.sh
sudo usermod -aG docker $USER

Log out and back in so the group change applies. Then confirm that both the engine and the Compose plugin answer.

docker --version
docker compose version

Our lab printed these two lines, and any recent Compose v2 or later release works the same way here.

Docker version 29.9.2, build a82e528
Docker Compose version v5.6.1

With Docker answering, the host is ready for both Forgejo and Woodpecker.

Run Forgejo if you don’t have one yet

Skip this section if your Forgejo already runs and jump to the OAuth app. For a fresh one, a single container with SQLite is enough for a team of a few people. The install lock plus the CLI user command means there is no web installer to click through, so the whole thing takes two commands after the file is written. Create the project directory and the compose file.

mkdir -p ~/forgejo && cd ~/forgejo
vim compose.yaml

Paste this file, the same one that ran in the lab. The last environment line is the one most guides leave out, and it’s the reason the webhook works at all on a LAN.

services:
  forgejo:
    image: codeberg.org/forgejo/forgejo:17
    container_name: forgejo
    restart: unless-stopped
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - FORGEJO__database__DB_TYPE=sqlite3
      - FORGEJO__security__INSTALL_LOCK=true
      - FORGEJO__server__ROOT_URL=http://192.168.1.158:3000/
      - FORGEJO__server__SSH_PORT=2222
      - FORGEJO__webhook__ALLOWED_HOST_LIST=private
    volumes:
      - ./data:/data
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3000:3000"
      - "2222:22"

Forgejo’s default ALLOWED_HOST_LIST is external, which refuses to deliver webhooks to private addresses like 192.168.x.x or 10.x.x.x. Pushes would land in Forgejo and Woodpecker would never hear about them, so setting it to private to allow RFC 1918 targets is required for any LAN install. We also learned that the container writes these variables into data/gitea/conf/app.ini. Deleting a line from the compose file later therefore does not revert the setting, and you have to change the value instead.

Start Forgejo, then create an admin account from the CLI with a generated password.

docker compose up -d
docker exec -u git forgejo forgejo admin user create --admin --username ops --email [email protected] --random-password --must-change-password=false

Copy the generated password straight away, because Forgejo prints it once and never again. The run on forge01 looked like this, with the password masked.

Forgejo 17 started with Docker Compose on Debian 13 and an admin user created with a random password

We also created a second account, eugene, with the same subcommand and used it for everything that follows; wherever you see eugene below, use your own login (ops works fine). Sign in to Forgejo, then use the + menu for New repository to create hello-ci, and New organization to create platform with a repository api inside it, which the agent section needs. If you would rather run Forgejo on a VM without containers, the Forgejo install guide covers that layout, and the Gitea install on Ubuntu 26.04 is the closest match on the Gitea side.

Register the OAuth2 application in Forgejo

Woodpecker keeps no passwords of its own. Every login is a Forgejo OAuth2 sign-in, and the token it gets back is also what it uses to list your repositories and create webhooks, so the OAuth app comes first. In Forgejo, open your avatar menu, go to Settings, then Applications, and scroll to Manage OAuth2 applications.

Forgejo Applications settings with the Woodpecker CI OAuth2 application name and redirect URI filled in
  1. 1Application name
  2. 2Redirect URI ends in /authorize
  3. 3Create application

Fill the form in this order, because the redirect URI is the one field that has to match Woodpecker exactly.

  1. Under Application name, type anything you will recognise later, such as Woodpecker CI.
  2. Make sure the Redirect URI ends in /authorize. It is your Woodpecker address plus /authorize, here http://192.168.1.158:8000/authorize, with the same scheme, host and port as WOODPECKER_HOST.
  3. Leave Confidential client ticked and click Create application.

Forgejo answers with the two values Woodpecker needs, and this is the only time it shows the secret in full. Paste both somewhere safe before you leave the page. If you lose the secret, Regenerate secret on the same screen issues a new one, and the old one stops working at once.

Forgejo shows the Client ID and Client secret of the new Woodpecker CI OAuth2 application, secret redacted
  1. 1Client ID goes in WOODPECKER_FORGEJO_CLIENT
  2. 2Client secret goes in WOODPECKER_FORGEJO_SECRET

The two marks map straight onto the compose file. Mark 1 shows that the Client ID goes in WOODPECKER_FORGEJO_CLIENT, which we pass through the FORGEJO_CLIENT line of the .env file. Mark 2 shows that the Client secret goes in WOODPECKER_FORGEJO_SECRET, the FORGEJO_SECRET line, and it always starts with gto_.

An app created under your user settings belongs to you. For a shared instance, a site admin can create the same app under Site administration, then Applications, so it does not vanish if your account does. On Gitea the flow and the /authorize callback are the same, and according to the Woodpecker Gitea docs only the variable prefix changes to WOODPECKER_GITEA, WOODPECKER_GITEA_URL, WOODPECKER_GITEA_CLIENT and WOODPECKER_GITEA_SECRET. We ran only the Forgejo side.

Write the Woodpecker compose file

The server and the first agent live in one compose project, which puts them on the same Docker network. The agent reaches the server as woodpecker-server:9000, so port 9000 never has to be published on the host. Create the directory and an .env file for the values that differ per install.

mkdir -p ~/woodpecker && cd ~/woodpecker
vim .env

Add these lines, using your own host address, your Forgejo login and the two OAuth values from the previous step.

WOODPECKER_HOST=http://192.168.1.158:8000
WOODPECKER_ADMIN=eugene
FORGEJO_URL=http://192.168.1.158:3000
FORGEJO_CLIENT=1c9b92e8-dd90-4004-bc37-7f93abf3fa5d
FORGEJO_SECRET=gto_replace_with_the_client_secret_from_forgejo

The agent secret is the shared password between server and agent, so generate a long random one rather than typing it. Lock the file down afterwards, because it now holds two secrets.

echo "AGENT_SECRET=$(openssl rand -hex 32)" >> .env
chmod 600 .env

WOODPECKER_ADMIN takes Forgejo login names, comma separated, and those users get the admin settings in Woodpecker. With the values in place, open the compose file.

vim compose.yaml

This is the exact file that ran on forge01, pinned to the release we tested. Check the Woodpecker releases page for anything newer before you copy the tag.

services:
  woodpecker-server:
    image: woodpeckerci/woodpecker-server:v3.20.1
    container_name: woodpecker-server
    restart: unless-stopped
    ports:
      - "8000:8000"
    volumes:
      - woodpecker-server-data:/var/lib/woodpecker/
    environment:
      - WOODPECKER_HOST=${WOODPECKER_HOST}
      - WOODPECKER_OPEN=true
      - WOODPECKER_ADMIN=${WOODPECKER_ADMIN}
      - WOODPECKER_FORGEJO=true
      - WOODPECKER_FORGEJO_URL=${FORGEJO_URL}
      - WOODPECKER_FORGEJO_CLIENT=${FORGEJO_CLIENT}
      - WOODPECKER_FORGEJO_SECRET=${FORGEJO_SECRET}
      - WOODPECKER_AGENT_SECRET=${AGENT_SECRET}

  woodpecker-agent:
    image: woodpeckerci/woodpecker-agent:v3.20.1
    container_name: woodpecker-agent
    command: agent
    restart: unless-stopped
    depends_on:
      - woodpecker-server
    volumes:
      - woodpecker-agent-config:/etc/woodpecker
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WOODPECKER_SERVER=woodpecker-server:9000
      - WOODPECKER_AGENT_SECRET=${AGENT_SECRET}
      - WOODPECKER_MAX_WORKFLOWS=2

volumes:
  woodpecker-server-data:
  woodpecker-agent-config:

A few lines deserve a reason. WOODPECKER_OPEN=true lets any Forgejo user sign in and register, which is fine on a private instance, but on a public Forgejo you need a limit. Setting it to false (the documented default) closes registration to everyone except the WOODPECKER_ADMIN users. To admit a team instead, keep it true and add WOODPECKER_ORGS=platform; that check applies to every login, admins included, so list an organization your own account belongs to. The Docker socket mount is what lets the agent start step containers. It also means anyone who can push a pipeline can run containers on this host, so connect only repositories you trust. WOODPECKER_MAX_WORKFLOWS=2 caps the agent at two workflows at a time, which matters once the matrix build fans out to four.

Start the server and agent

Pull the images and bring both containers up from the ~/woodpecker directory.

docker compose pull -q
docker compose up -d

Both containers need a few seconds to pass their health checks. After that, the status table and the version endpoint should both report the tag from the compose file.

docker compose ps --format "table {{.Name}}\t{{.Image}}\t{{.Status}}"
curl -s http://192.168.1.158:8000/version

Healthy on both lines and the pinned release from the API is what you want to see.

Woodpecker server and agent containers healthy on Debian 13 with the version endpoint answering

Two log lines looked odd but are harmless. The agent reports its version as next-3f1bb0bd0d rather than the release tag, and the server warns that WOODPECKER_GRPC_SECRET is not set; generated a temporary random secret on every start. We left that variable unset in the lab, and the agent reconnected on its own after both containers were recreated during the upgrade. The secret only signs the gRPC session tokens, so a new one at each start just makes agents log in again.

Sign in and enable a repository

Open http://192.168.1.158:8000 in the browser where you are already signed in to Forgejo. Woodpecker offers your Forgejo instance as the only login choice, labelled with its hostname.

Woodpecker CI sign-in page offering the Forgejo instance as the login forge

Click it to Sign in with your Forgejo. The request goes from Woodpecker to Forgejo’s authorize endpoint, and Forgejo asks whether this app may act on your behalf.

Forgejo asks the user to authorize the Woodpecker CI OAuth2 application

Click Authorize Application. Forgejo sends you back to /authorize on Woodpecker with a code, Woodpecker swaps it for a token server-side, and you land on an empty dashboard. Because your login is listed in WOODPECKER_ADMIN, a gear icon for the admin settings appears in the top bar. Open Add repository (the /repos/add page), and Woodpecker lists every Forgejo repository your account can admin.

Woodpecker Add repository list showing Forgejo repositories with an Enable button for platform/api

Click Enable next to the repository you want to build. We enabled eugene/hello-ci first and platform/api later for the agent section, which is the one still waiting in the screenshot. Enabling does the wiring for you, because Woodpecker calls the Forgejo API with your token and creates a webhook on that repository pointing at its own /api/hook endpoint. You can see it under the repository’s Settings, then Webhooks.

Forgejo repository webhooks page with the hook Woodpecker created pointing at its /api/hook endpoint, token redacted

Woodpecker created this hook on Enable, and it is the other end of the handshake. Its URL is built from WOODPECKER_HOST, so if you change the host later, disable and re-enable the repository (or use the repair option in its Woodpecker settings) so the hook follows. The green dot means the last delivery succeeded, while a red one sends you to the webhook error near the end of this guide.

Push the first .woodpecker.yaml pipeline

The test repository holds a ten-line Go program and one unit test, so the pipeline has something real to run. You need git on your workstation with user.name and user.email set. Clone the empty repository and start with the module file, since go test refuses to run outside a module.

git clone http://192.168.1.158:3000/eugene/hello-ci.git
cd hello-ci
vim go.mod

The module path matches the example.com/hello-ci line you will see in the test output.

module example.com/hello-ci

go 1.24

Next, the program itself.

vim main.go

It prints one greeting and exposes the function the test calls.

package main

import "fmt"

func Greet(name string) string { return "hello, " + name }

func main() { fmt.Println(Greet("woodpecker")) }

Then the unit test.

vim main_test.go

One assertion is enough to prove the test step really runs.

package main

import "testing"

func TestGreet(t *testing.T) {
	if got := Greet("ci"); got != "hello, ci" {
		t.Fatalf("got %q", got)
	}
}

Now create the pipeline file at the root of the repository.

vim .woodpecker.yaml

Add two steps that share one Go image. The top-level when limits the pipeline to pushes, pull requests and manual runs, and without it Woodpecker’s linter attaches a warning to every run.

when:
  - event: [push, pull_request, manual]

steps:
  - name: test
    image: golang:1.27
    commands:
      - go version
      - go vet ./...
      - go test -v ./...

  - name: build
    image: golang:1.27
    commands:
      - go build -o hello .
      - ./hello

Each step runs in a fresh container from its image, but all steps share the cloned workspace. That is why build can run the binary it just compiled without any artifact handling. Commit the file and push it.

git add go.mod main.go main_test.go .woodpecker.yaml
git commit -m "add woodpecker pipeline"
git push origin main

The push reaches Forgejo, the webhook reaches Woodpecker, and the agent picks up the workflow within a second or two. The run below took 34 seconds on forge01, and much of that was the first pull of the golang:1.27 image.

Woodpecker pipeline on main with clone, test and build green and the go test output in the step log

The step logs read like a terminal session with one group per command. Pulled together, the output of both steps was this.

go version go1.27.3 linux/amd64
=== RUN   TestGreet
--- PASS: TestGreet (0.00s)
PASS
ok  	example.com/hello-ci	0.001s
hello, woodpecker

Woodpecker then reports back to Forgejo through the commit status API, which closes the loop. The commit on the repository page now carries a check mark that links to the run.

Forgejo repository page showing the green Woodpecker commit status on the latest commit

The Woodpecker status on the commit is posted under the context ci/woodpecker/push/woodpecker with the description “Pipeline was successful”, as the Forgejo status API showed for this commit. Pull request runs report under their own context, ci/woodpecker/pr/woodpecker, and that is the one to require in a Forgejo branch protection rule when you want CI to block merges rather than advise. Once the matrix in the next section is in place, each workflow gets a numbered suffix (ci/woodpecker/pr/woodpecker/1 to /4), so require those or a pattern such as ci/woodpecker/pr/* instead.

Fan out with a matrix build and a secret

A matrix turns one pipeline file into several workflows, one per combination of the values you list. We built the program with two Go releases for two CPU architectures, which gives four workflows, and added a publish step that reads a token from a Woodpecker secret. Create the secret first from the repository’s Settings in Woodpecker, under Secrets and Add secret.

Woodpecker repository settings adding the deploy_token secret that the matrix pipeline reads with from_secret
  1. 1Name used by from_secret
  2. 2Value

Type deploy_token as the Name used by from_secret, exactly as the pipeline will reference it, and paste the token itself as the Value. Leave the plugin list empty so ordinary steps can read it, keep the events you need ticked, and click Add secret at the bottom. The page warns about exposing secrets to pull requests for a good reason, since a pull request from a fork could print it. Our publish step only runs on pushes to main, which keeps the secret out of pull request builds even though that event box is ticked. Now replace the pipeline file with the matrix version.

vim .woodpecker.yaml

This is the final file from the lab, after the fix explained right under it.

matrix:
  GO_VERSION:
    - "1.26"
    - "1.27"
  GOARCH:
    - amd64
    - arm64

when:
  - event: [push, pull_request, manual]

steps:
  - name: test
    image: golang:${GO_VERSION}
    environment:
      GOARCH: amd64
    commands:
      - go version
      - go test ./...

  - name: build
    image: golang:${GO_VERSION}
    environment:
      GOARCH: ${GOARCH}
      CGO_ENABLED: "0"
    commands:
      - go build -o dist/hello-linux-${GOARCH} .
      - ls -l dist/

  - name: publish
    image: alpine:3.22
    environment:
      DEPLOY_TOKEN:
        from_secret: deploy_token
    commands:
      - echo "would upload dist/hello-linux-${GOARCH} built with Go ${GO_VERSION}"
      - echo "deploy token is $${#DEPLOY_TOKEN} characters long"
    when:
      - event: push
        branch: main

Woodpecker substitutes ${GO_VERSION} and ${GOARCH} into the YAML text before parsing it, which is why the image tag can come from the matrix. The doubled dollar in $${#DEPLOY_TOKEN} is the escape that keeps Woodpecker’s substitution away from that variable, so the shell inside the container expands it at run time. The step log shows the command after substitution, so a correct escape appears there as a plain ${#DEPLOY_TOKEN}, which is a quick way to check your quoting.

Our first version of this file had no GOARCH: amd64 override on the test step, and both arm64 workflows failed. Matrix values are not only substituted into the YAML. They are also exported as environment variables into every step of that workflow, so go test quietly built an arm64 test binary and tried to execute it on an amd64 agent.

Woodpecker matrix pipeline where the arm64 workflows fail go test with exec format error because GOARCH leaks into every step

The tell is the error text exec format error on the test binary, while go version in the same log still says linux/amd64. Naming a matrix axis after a variable your tools already read is convenient for the build step and a trap for every other step, and GOARCH, GOOS and NODE_ENV are the usual offenders. Either override it per step, as we did, or give the axis a neutral name like TARGET_ARCH and map it only where you need it.

With the override in place, all four workflows went green in about 15 seconds each, running two at a time because of WOODPECKER_MAX_WORKFLOWS=2.

Woodpecker matrix pipeline with four green workflows for Go 1.26 and 1.27 on amd64 and arm64, publish step log open

The arm64 build log shows the cross-compiled binary, and the publish step proves the secret arrived without printing it.

-rwxr-xr-x 1 root root 2342320 Nov  1 04:38 hello-linux-arm64
would upload dist/hello-linux-arm64 built with Go 1.27
deploy token is 21 characters long

The matrix workflow docs cap a matrix at 64 workflows, which sounds generous until a third axis multiplies the first two. Each matrix badge in the sidebar, such as GOARCH arm64 next to GO_VERSION 1.27, tells you which combination a failed workflow belongs to.

Keep an organization on its own agents

By default, the instance-wide agent we started in compose picks up work from every repository, including those of any organization. The 3.20 release added a switch that reverses this for one organization, so its workflows only go to agents registered to that organization. That is what you want when a team brings its own build box with credentials or hardware the rest of the instance should not touch. In Woodpecker, open the organization (ours is platform), then Settings and Agents.

Woodpecker organization Agents settings with Run pipelines only on organization agents switched on
  1. 1Run pipelines only on organization agents
  2. 2Add agent
  1. Tick Run pipelines only on organization agents. It saves immediately, and the API then reports "require_org_agents":true for the organization.
  2. Click Add agent to register the organization’s own agent, covered next.

To have something to schedule, enable platform/api on the Add repository page, then commit the whoami pipeline shown further down as .woodpecker.yaml in a clone of platform/api and push it. We ticked the box before any organization agent existed, on purpose, to see what happens to that push. The pipeline for platform/api sat in pending while the instance agent idled next to it, and the queue API showed the label woodpecker-ci.org/org-agents-only set to true on the waiting workflow. Turning the switch on with no agent online stalls every build of that organization, so register the agent first on a live system.

Woodpecker pipeline for platform/api stuck pending because the organization accepts only its own agents and none is registered

In the Add agent form, give the agent a name and click Save agent. Woodpecker generates a token for this one agent, and that token takes the place of the shared AGENT_SECRET for it.

Woodpecker organization agent platform-runner saved, with its agent token shown under the name and redacted
  1. 1Agent name
  2. 2Token goes in WOODPECKER_AGENT_SECRET

We used platform-runner as the Agent name, which is how it shows up in the organization’s agent list. The second mark is the reminder that the Token goes in WOODPECKER_AGENT_SECRET of the new agent container, so copy it now. We ran that agent as its own compose project on the same host, joined to the server’s network so it can still reach woodpecker-server:9000, with the token in a separate .env.

mkdir -p ~/platform-agent && cd ~/platform-agent
vim .env

The file needs one line, holding the token from the agent page.

PLATFORM_AGENT_TOKEN=paste-the-agent-token-from-woodpecker

Then create the compose file for the agent itself.

vim compose.yaml

The network block points at woodpecker_default, the network Compose created for the ~/woodpecker project.

services:
  platform-agent:
    image: woodpeckerci/woodpecker-agent:v3.20.1
    container_name: platform-agent
    command: agent
    restart: unless-stopped
    volumes:
      - platform-agent-config:/etc/woodpecker
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WOODPECKER_SERVER=woodpecker-server:9000
      - WOODPECKER_AGENT_SECRET=${PLATFORM_AGENT_TOKEN}
      - WOODPECKER_HOSTNAME=platform-runner
    networks:
      - woodpecker

networks:
  woodpecker:
    name: woodpecker_default
    external: true

volumes:
  platform-agent-config:

On a real second machine, drop the networks block, publish 9000:9000 on the server and point WOODPECKER_SERVER at the server’s address. The server speaks plain gRPC on port 9000, so traffic that leaves the host needs a TLS-terminating proxy in front of it before you set WOODPECKER_GRPC_SECURE=true on the agent, because the agent token crosses the wire on every connection. Setting a fixed WOODPECKER_GRPC_SECRET on the server at the same time is good practice; agents re-authenticate after a restart either way, as ours did. Start the agent.

docker compose up -d

Within seconds the pending pipeline started, and the organization’s agent list showed platform-runner with a last contact of “just now”.

Woodpecker platform organization Agents list with platform-runner connected and org-only scheduling enabled

To prove which agent ran it, the platform/api pipeline prints the built-in CI_AGENT_ID. It is escaped with $$ again, so the shell expands it rather than Woodpecker.

when:
  - event: [push, manual]

steps:
  - name: whoami
    image: alpine:3.22
    commands:
      - echo "agent $${CI_AGENT_ID} ran pipeline $${CI_PIPELINE_NUMBER} for $${CI_REPO}"

Agent 1 is the instance agent from the main compose file and agent 2 is platform-runner, so the log line settles it.

Woodpecker platform/api pipeline finished on the organization agent, whoami step printing agent 2

The same switch exists for personal repositories under your user settings, in the Agents tab next to Secrets. According to the agent configuration docs, the restriction applies to pipelines created after you flip it, so anything already queued keeps the old behaviour.

Avoid the envsubst backslash regression

The compose file pins 3.20.1 rather than a floating v3 tag because the release before it broke pipelines that nobody had touched. Woodpecker runs an envsubst pass over the YAML text before parsing it, the same pass that expands ${GO_VERSION}. Release 3.20.0 swapped the library behind that pass for a fork, and the fork stopped unescaping \\ and \/ outside ${...}. The 3.20.0 release notes now carry a banner telling everyone to use 3.20.1, which restores the Drone envsubst behaviour.

We reproduced it before upgrading. This step checks a release tag against a regex written with a doubled backslash, the way Drone-era pipelines and the Woodpecker docs’ own branch patterns write it.

steps:
  - name: tag-format
    image: alpine:3.22
    commands:
      - |
        printf "%s\n" 'pattern: ^v[0-9]+\\.[0-9]+$'
        echo v3.20 | grep -E '^v[0-9]+\\.[0-9]+$'

On 3.20.0 the doubled backslash reached the shell untouched. That made grep look for a literal backslash, which matched nothing, so the step exited 1 on a pipeline file that had always passed before.

Woodpecker 3.20.0 pipeline where the tag-format step fails because the doubled backslash reaches grep

After changing both image tags to v3.20.1 and running docker compose pull -q and docker compose up -d, a restart of the very same pipeline passed. The step log shows the pattern after substitution, and the backslash is now single.

pattern: ^v[0-9]+\.[0-9]+$
v3.20

The pipeline view after the upgrade shows the same commit, now green.

Woodpecker 3.20.1 runs the same tag-format step and grep matches once the doubled backslash is unescaped

Upgrading in place kept everything. The SQLite database in the woodpecker-server-data volume, the enabled repository, the login and the pipeline history all survived the container swap. If you landed on 3.20.0 and a regex, a path with backslashes or a branch: '*\\/*' filter misbehaves, the upgrade is the whole fix. We found no report of ${VAR:-default} defaults breaking in that release, because the change was limited to backslash escapes outside ${...}.

Put HTTPS in front with Caddy

Plain HTTP on a LAN is fine for a homelab, but OAuth tokens and session cookies cross that connection, so anything reachable from outside needs TLS. Caddy is the shortest route because it requests and renews Let’s Encrypt certificates on its own through the HTTP-01 challenge, which works with any DNS provider. Point an A record for each name at the host, open ports 80 and 443, and create the Caddyfile.

mkdir -p ~/caddy
vim ~/caddy/Caddyfile

It needs two site blocks, one per service, both proxying to the ports published on the host.

ci.example.com {
	reverse_proxy 127.0.0.1:8000
}

git.example.com {
	reverse_proxy 127.0.0.1:3000
}

Run Caddy on the host network so 127.0.0.1 means the Docker host, with a named volume that keeps the certificates across restarts.

docker run -d --name caddy --restart unless-stopped --network host -v ~/caddy/Caddyfile:/etc/caddy/Caddyfile:ro -v caddy_data:/data caddy:2

Our lab names had no public DNS, so we checked this file with caddy validate, which printed “Valid configuration”. We then ran the same command with Caddy’s local_certs option added, which signs from Caddy’s internal CA instead of Let’s Encrypt, and both names answered through the proxy over HTTP/2.

curl -sk --resolve ci.example.com:443:127.0.0.1 https://ci.example.com/version

Woodpecker’s version came back through Caddy, and the same check against git.example.com returned HTTP 200.

{"source":"https://github.com/woodpecker-ci/woodpecker","version":"3.20.1"}

Once the proxy works, every place that names a URL has to follow it, or the handshake breaks at the first hop. We did not repeat the OAuth sign-in over the public names in the lab, so treat this as the order to walk through on yours.

  1. Set FORGEJO__server__ROOT_URL=https://git.example.com/ in the Forgejo compose file and run docker compose up -d.
  2. Change the OAuth app’s redirect URI in Forgejo to https://ci.example.com/authorize.
  3. Set WOODPECKER_HOST=https://ci.example.com and FORGEJO_URL=https://git.example.com in ~/woodpecker/.env, then run docker compose up -d.
  4. Change FORGEJO__webhook__ALLOWED_HOST_LIST to ci.example.com (or external,private) and recreate Forgejo. The public name resolves to a public address, which private does not cover, so without this every webhook is denied again.
  5. Repair or re-enable each repository in Woodpecker so its webhook points at the new host.
  6. Change the published ports to 127.0.0.1:3000:3000 and 127.0.0.1:8000:8000 in the two compose files, so plain HTTP is no longer reachable from outside and only Caddy faces the network.

After the last step, Forgejo reaches Woodpecker and Woodpecker reaches Forgejo through the public names, so on a home network behind NAT your router has to support hairpin connections, or the host needs split DNS that resolves both names to its LAN address. If the hosts cannot take inbound port 80, use a DNS-01 challenge instead, which needs a Caddy build with your DNS provider’s plugin. If you prefer Nginx in front, the Jenkins on Ubuntu 26.04 guide shows the reverse proxy block for another CI server on the same pattern.

Each of these came from breaking one side of the handshake on purpose in the lab, so the text below is exactly what you’ll see. Start with the one that matches the symptom. No pipeline after a push points at the webhook, a failed login points at OAuth, and an agent that keeps restarting points at the agent secret.

Error: “webhook can only call allowed HTTP servers (check your webhook.ALLOWED_HOST_LIST setting)”

The push shows up in Forgejo, nothing happens in Woodpecker, and the webhook’s dot turns red. Forgejo’s log explains it once you grep for the failed delivery.

docker logs forgejo 2>&1 | grep "Unable to deliver" | tail -1

With ALLOWED_HOST_LIST back on its default of external, Forgejo refused to post to the Woodpecker host on the LAN.

Forgejo log showing webhook can only call allowed HTTP servers when ALLOWED_HOST_LIST is external

Set FORGEJO__webhook__ALLOWED_HOST_LIST=private, recreate the container and push again. A CIDR like 10.0.0.0/8 or the Woodpecker hostname works too. Check the value Forgejo actually loaded in data/gitea/conf/app.ini, because removing the environment line leaves the old value in that file.

Error: “Authorization failed. Unregistered Redirect URI”

Clicking the Forgejo button on Woodpecker’s login page lands on this Forgejo page instead of the consent screen. It means the redirect_uri Woodpecker sent, built from WOODPECKER_HOST plus /authorize, is not one of the URIs saved in the OAuth app. We triggered it by asking for https://ci.example.com/authorize while the app only knew the HTTP LAN address.

Forgejo Authorization failed page reporting Unregistered Redirect URI for the Woodpecker OAuth application

Compare the two strings character by character, since http against https, a missing :8000 or a trailing slash on WOODPECKER_HOST are the usual culprits. Fix whichever side is wrong. The redirect URI box accepts one URI per line, so during a migration you can keep the old and the new address side by side.

Error: “agent could not auth: individual agent not found by token”

The agent starts, logs one fatal line and exits, and the restart policy keeps bringing it back to fail again. We reproduced it with an extra agent container started with a made-up secret.

Woodpecker agent log with agent could not auth individual agent not found by token after a wrong secret

The message says the server accepted the value neither as the shared WOODPECKER_AGENT_SECRET nor as the token of any registered agent. For the instance agent, make sure server and agent read the same AGENT_SECRET from .env, and recreate both after changing it. For an organization agent, copy the token from its agent page again, since a stray space or a truncated paste produces the same error. If you would rather keep runners next to your forge instead of in a separate CI, the GitLab Runner guide shows how that model handles the same registration step.

Keep reading

What Is DeepSeek Harness? Install dsh and Run Your First Agent AI What Is DeepSeek Harness? Install dsh and Run Your First Agent Best UI Applications for Managing Docker Containers Containers Best UI Applications for Managing Docker Containers DeepSeek Harness With Ollama: Local Model Setup, vLLM, llama.cpp AI DeepSeek Harness With Ollama: Local Model Setup, vLLM, llama.cpp CapRover Docker Install on Debian 13: Deploy Your First App Containers CapRover Docker Install on Debian 13: Deploy Your First App Best OpenShift Books for Admins and EX280 Candidates Books Best OpenShift Books for Admins and EX280 Candidates Best Kubernetes Networking Books: Cilium, Istio, Linkerd, eBPF Books Best Kubernetes Networking Books: Cilium, Istio, Linkerd, eBPF

Leave a Comment

Press ESC to close