Is Woodpecker the CI you want?
Woodpecker is a CI/CD engine that sits next to the forge you already use instead of replacing it. Your code stays on Forgejo, Gitea, GitHub, GitLab or Bitbucket; Woodpecker receives their webhooks, reads the pipeline file in the repo (.woodpecker.yaml or a .woodpecker/ folder) and reports pass or fail back. It has no accounts of its own: people sign in with their forge login.
There are two parts. The server gives you the web interface and API on port 8000 and talks to agents on port 9000. The agent picks up work and starts each step as a Docker container. Per the project's README.md, the server idles at roughly 100 MB of memory and the agent at 30 MB, so it is the builds that size the machine.
The server that fits your builds
What makes builds fast is what they run: compilers, test suites, image builds. By default an agent takes a single workflow at once (WOODPECKER_MAX_WORKFLOWS changes that), so ask how many builds you want side by side and give each one cores, memory and disk. As a rough guide of our own:
- A handful of repos: the Core i5-9500 desktop machine with 32 GB of memory and an NVMe disk, which our server list suggests for build jobs, or a small E3-1585L v5 cartridge server, also on NVMe.
- A team pushing all day: a two-processor Xeon server such as the E5-2630L v3 or Gold 6130, where several workflows can run in parallel without fighting.
- Builds for ARM: Woodpecker ships linux/arm64 images, so a made-to-order 64-core Ampere Altra can build arm64 natively; see ARM dedicated servers.
Every option is on our dedicated server list, with its memory, disk options, port speed and cities. Ready servers arrive within 30 minutes of approval. One built to your spec takes longer: hours when the parts are already on hand, more when they have to be bought in, and the Ampere Altra one to two weeks. You get the whole machine, root access and an unmetered port, month to month with no contract.
You manage the server; we keep the hardware and the network running.
Why your CI should not share a server
The agent needs /var/run/docker.sock, Docker's control socket, and Woodpecker's migration notes say its image must run as root to use it. In our own words: any process with access to that socket can launch a container that has the host's disk mounted inside, which is as good as root. Woodpecker keeps that power away from ordinary pipelines, and you decide where the exceptions are:
- Only a server admin can mark a repository trusted. Trusted repos may use escalated options such as mounting host folders, including the Docker socket.
- Steps with
privileged: trueonly work in trusted repos, and the docs advise keeping that to private setups. - Plugins only run privileged if you list their image, with an exact tag, in
WOODPECKER_PLUGINS_PRIVILEGED. Since version 3.0 none is privileged by default. - Pull requests from forks wait for someone to approve them before running, by default; you can require approval for more events.
- Repo admins can register their own agents unless you set
WOODPECKER_DISABLE_USER_AGENT_REGISTRATION=true, which the docs suggest when you keep global secrets.
Even an untrusted pipeline is somebody's code running on your hardware. That is why we suggest a CI-only server: if something goes wrong there, your forge, your database and your live site are on other machines.
Up and running in an evening
These steps use Forgejo as the forge, on Ubuntu 24.04 (its package archive includes caddy; 22.04's does not), and Docker Engine 28 (or a later release) plus the Compose plugin, set up by Docker's own instructions. Before 28, ports bound to 127.0.0.1 could be exposed to neighbouring machines (moby#45610).
- Give your CI address (ci.example.com in the examples) an A and an AAAA record for this server, and wait until the name resolves. Caddy fetches its certificate as soon as it starts serving the name.
- In your Forgejo user settings (the docs point to /user/settings/), add an OAuth2 application with the redirect URI
https://ci.example.com/authorize. It must match exactly. An instance admin can create it site-wide instead. - If Forgejo runs on this same server, allow its webhooks to reach a local address: in app.ini, in the
[webhook]section, setALLOWED_HOST_LIST=external,loopback. Also put the agent on Forgejo's Docker network withWOODPECKER_BACKEND_DOCKER_NETWORK. - Paste the first block below. It sets up the firewall, creates /opt/woodpecker with the Compose file inside, and starts a private .env holding a fresh agent secret.
- On its own, outside any pasted block, open the file with
nano /opt/woodpecker/.env, then type inWOODPECKER_HOST=https://ci.example.com(no slash at the end),WOODPECKER_ADMINset to your Forgejo username, andWOODPECKER_FORGEJO_URL,WOODPECKER_FORGEJO_CLIENTandWOODPECKER_FORGEJO_SECRETfrom step 2. Typing the secret into the editor keeps it out of your shell history. Save and exit. - Paste the second block. It starts Woodpecker and makes it public only if every check before that passes.
- Open https://ci.example.com, sign in with Forgejo and switch on your first repo.
# Firewall before anything listens: IPv6 on, SSH in, then enable
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
# --force skips: Command may disrupt existing ssh connections. Proceed with operation (y|n)?
ufw --force enable
ufw allow 80,443/tcp
# Folder, Compose file, and an .env that is private from the moment it exists
mkdir -m 700 /opt/woodpecker
cat > /opt/woodpecker/compose.yaml <<'EOF'
services:
woodpecker-server:
image: woodpeckerci/woodpecker-server:v3
restart: unless-stopped
ports:
- 127.0.0.1:8000:8000
volumes:
- wp-server:/var/lib/woodpecker/
env_file: .env
environment:
- WOODPECKER_OPEN=false
- WOODPECKER_FORGEJO=true
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3
command: agent
restart: always
depends_on:
- woodpecker-server
volumes:
- wp-agent:/etc/woodpecker
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WOODPECKER_SERVER=woodpecker-server:9000
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
- WOODPECKER_MAX_WORKFLOWS=2
volumes:
wp-server:
name: wp-server
wp-agent:
name: wp-agent
EOF
install -m 600 /dev/null /opt/woodpecker/.env
echo "WOODPECKER_AGENT_SECRET=$(openssl rand -hex 32)" >> /opt/woodpecker/.env
Compared with the example in Woodpecker's docs, these are our changes: the port listens on 127.0.0.1 only, sign-up is forced off (the docs' example turns it on), secrets come from .env, the server gets a restart policy (the docs' example already restarts the agent), the agent runs two workflows at once and the volumes have short fixed names. For GitHub, swap in WOODPECKER_GITHUB=true and its _CLIENT and _SECRET, and create an OAuth App there, not a GitHub App.
# Nothing after a failed check runs, so nothing goes public by accident
E=/opt/woodpecker/.env
grep -Eq '^WOODPECKER_ADMIN=.+' "$E" && grep -Eq '^WOODPECKER_FORGEJO_SECRET=.+' "$E" \
&& docker compose -f /opt/woodpecker/compose.yaml up -d \
&& { docker compose -f /opt/woodpecker/compose.yaml port woodpecker-server 8000 \
| grep -qx '127.0.0.1:8000' \
|| { docker compose -f /opt/woodpecker/compose.yaml down; false; }; } \
&& apt-get install -y caddy \
&& echo 'ci.example.com {
reverse_proxy 127.0.0.1:8000
}' > /etc/caddy/Caddyfile \
&& systemctl reload caddy \
&& echo published
Watch for the word published at the end. If it never appears, whichever step failed (a missing admin or Forgejo secret, the start, port 8000 listening somewhere other than loopback, or Caddy failing to install) halted everything after it. If the port check is the one that failed, the block also shuts the containers down again, so the UI is not left reachable from outside; fix that one thing and paste the block again. In the first block, the comment quotes the question plain ufw enable asks, which would swallow your next pasted line; --force skips it. A mapping written without an address opens the port on 0.0.0.0 and [::] alike. IPv4 connections to it never meet ufw's rules, and IPv6 connections escape them as well whenever Docker handles IPv6 itself. Tying port 8000 to 127.0.0.1 holds the UI inside this machine, as long as Engine 28 or later is installed. Caddy runs on the host like any program, so it needs the 80 and 443 rule.
Deciding who gets in
Sign-up is off by default (WOODPECKER_OPEN=false). Turned on, any account on your forge could log in, which on a public forge means anyone. There is no built-in admin password; your protection is having sign-up off and your own username in WOODPECKER_ADMIN before the HTTPS address goes live. You cannot sign in any earlier anyway, because the forge sends you back to WOODPECKER_HOST.
People in WOODPECKER_ADMIN are still allowed to create their account on first sign-in while sign-up is off; that detail comes from the source file server/api/login.go. woodpecker-cli admin user add brings in colleagues; alternatively, turn sign-up on and limit it to your organisations with WOODPECKER_ORGS.
Settings in .env become environment variables inside the containers, and docker inspect shows them to any user with Docker rights on the box. Woodpecker accepts a _FILE version of each secret if you would rather keep them in files.
Where your data lives, and keeping a copy
Out of the box Woodpecker keeps a SQLite file in /var/lib/woodpecker, holding every user and repository, and it stores build logs there too. The docs expect those logs to make it grow considerably, and Woodpecker neither archives old data nor backs anything up. For larger installs the docs point to Postgres (version 11 or later) or MySQL/MariaDB; you create the database yourself and point WOODPECKER_DATABASE_DATASOURCE at it.
The script below copies everything this setup needs to come back: the folder with compose.yaml and .env, plus both volumes. It pauses the server while it copies, so pick a quiet time; the agent reconnects afterwards. Schedule it with a root crontab line like 15 4 * * * bash /usr/local/sbin/wp-backup.sh >> /var/log/wp-backup.log 2>&1, and move the archives to another machine: ones that only live on this server vanish with it. If you use Postgres, have the script save a pg_dump -Fc -f file next to the archive.
# save as /usr/local/sbin/wp-backup.sh, run with: bash /usr/local/sbin/wp-backup.sh
set -euo pipefail
umask 077
OUT=/var/backups/wp
CF=/opt/woodpecker/compose.yaml
VOL=var/lib/docker/volumes
mkdir -p -m 700 "$OUT"
ARCHIVE="$OUT/wp-$(date +%F-%H%M).tar.gz"
docker compose -f "$CF" stop woodpecker-server
trap 'docker compose -f "$CF" start woodpecker-server' EXIT
tar -czf "$ARCHIVE" -C / opt/woodpecker "$VOL/wp-server" "$VOL/wp-agent"
tar -tzf "$ARCHIVE" > /dev/null
echo "ok $ARCHIVE"
To roll back to an archive on the live server, work as root in a single shell:
- Pick the newest archive (or set the name yourself) and test it:
ARCHIVE=$(ls -t /var/backups/wp/wp-*.tar.gz | head -n 1); echo "$ARCHIVE"; tar -tzf "$ARCHIVE" > /dev/null && echo ok. Without ok, do not continue. cd /opt/woodpecker && docker compose downstops both containers; the named volumes stay.- Save the current state and test that copy as well:
cd / && V=var/lib/docker/volumes && KEEP=/var/backups/wp/keep-$(date +%s).tar.gz && tar -czf "$KEEP" opt/woodpecker "$V/wp-server" "$V/wp-agent" && tar -tzf "$KEEP" > /dev/null && echo ok. Without ok, do not continue. - Empty the two volumes, which also clears out old SQLite -wal and -shm leftovers:
find "/$V/wp-server/_data" "/$V/wp-agent/_data" -mindepth 1 -delete. tar -xzf "$ARCHIVE" --numeric-owner -C /brings the files back with their original owners.- Bring it back with
cd /opt/woodpecker && docker compose up -d. Sign in and run a pipeline; once both work, remove the safety copy withrm "$KEEP".
Staying up to date, and the licence
Woodpecker publishes no latest image, so an update never surprises you with a new major version. v3 follows the 3.x releases; a full version number such as v3.x.y stays put until you edit it. Each new version upgrades the database by itself when it starts, so run the backup first and check the migrations page before moving to a new major version. Then run cd /opt/woodpecker && docker compose pull --quiet && docker compose up -d to fetch the new version and swap it in. Going back means the old image tag plus the archive you took before the update.
Woodpecker's code is under Apache-2.0 (its LICENSE and README.md), and its documentation under CC BY-SA 4.0. This is how we read it, and it is not legal advice: Apache-2.0 is permissive, so you never have to publish source code. If you hand copies to others, changed or not, include the licence and copyright notices and mark the files you changed. Comparing options? Our Jenkins server and Forgejo pages cover the neighbours.
Questions people ask
- Will Woodpecker work with my GitHub repos?
- Yes. Register an OAuth App under GitHub's developer settings with the callback https://your-ci-address/authorize, then set WOODPECKER_GITHUB=true with the client ID and secret. The docs warn against a GitHub App, which does not refresh user tokens yet.
- Do I need Postgres from day one?
- No. SQLite needs no setup and is the default. Bigger installs are where the docs bring in Postgres or MySQL/MariaDB; if you expect many repos and long logs, our advice is to choose before you start.
- How many builds can run at the same time?
- Each agent runs one workflow by default. Raise WOODPECKER_MAX_WORKFLOWS on the agent, or add more agents, and size the server for that many builds at once.
- Can I add a second build machine later?
- Yes. Another agent anywhere can join with the server's address on port 9000 and the same WOODPECKER_AGENT_SECRET. When that link crosses the internet, the docs ask for TLS on it, switched on by setting WOODPECKER_GRPC_SECURE=true for the agent.
- What if I lock SSH out with a firewall rule?
- Rack servers and blades include a remote console that works outside the operating system, giving you the screen and the power button, and so do two cartridge models, m510 and m710x, so you can undo the rule yourself. The remaining cartridges only offer power control, and our server list does not mention a console for the desktop machine. Failing that, every server lets you start over with a clean system install from its control panel.