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

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 CI | Forgejo Actions | |
|---|---|---|
| Where it runs | Separate server plus agents, signs in through an OAuth2 app | Built into Forgejo, jobs go to a separate forgejo-runner |
| Pipeline file | .woodpecker.yaml or a .woodpecker/ folder | .forgejo/workflows/, falling back to .github/workflows/ |
| Syntax | Its own, built on steps, image, commands, when and matrix | GitHub Actions style, described by Forgejo as familiar, not compatible |
| Forges | Forgejo, Gitea, GitHub, GitLab, Bitbucket and others | Forgejo only |
| Step isolation | Every step is a container image | Docker 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.
- Docker Engine with the Compose plugin (the lab ran Docker 29.9.2 and Compose v5.6.1 from get.docker.com).
- A user in the
dockergroup, so the compose commands below run without sudo. - Ports 3000 (Forgejo) and 8000 (Woodpecker) reachable from your browser, plus 443 later if you add the Caddy front end.
- 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.

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.

- 1Application name
- 2Redirect URI ends in /authorize
- 3Create application
Fill the form in this order, because the redirect URI is the one field that has to match Woodpecker exactly.
- Under Application name, type anything you will recognise later, such as
Woodpecker CI. - Make sure the Redirect URI ends in /authorize. It is your Woodpecker address plus
/authorize, herehttp://192.168.1.158:8000/authorize, with the same scheme, host and port asWOODPECKER_HOST. - 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.

- 1Client ID goes in WOODPECKER_FORGEJO_CLIENT
- 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.

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.

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.

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.

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.

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.

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.

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.

- 1Name used by from_secret
- 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.

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.

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.

- 1Run pipelines only on organization agents
- 2Add agent
- Tick Run pipelines only on organization agents. It saves immediately, and the API then reports
"require_org_agents":truefor the organization. - 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.

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.

- 1Agent name
- 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”.

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.

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.

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.

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.
- Set
FORGEJO__server__ROOT_URL=https://git.example.com/in the Forgejo compose file and rundocker compose up -d. - Change the OAuth app’s redirect URI in Forgejo to
https://ci.example.com/authorize. - Set
WOODPECKER_HOST=https://ci.example.comandFORGEJO_URL=https://git.example.comin~/woodpecker/.env, then rundocker compose up -d. - Change
FORGEJO__webhook__ALLOWED_HOST_LISTtoci.example.com(orexternal,private) and recreate Forgejo. The public name resolves to a public address, whichprivatedoes not cover, so without this every webhook is denied again. - Repair or re-enable each repository in Woodpecker so its webhook points at the new host.
- Change the published ports to
127.0.0.1:3000:3000and127.0.0.1:8000:8000in 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.
Fix the errors on the Forgejo link
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.

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.

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.

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.