8 min read
Self-hosting Woodpecker CI
Back to blog

Having just started using Codeberg instead of GitHub for hosting my projects’ repositories, I quickly wanted to have a CI/CD system running.
My first use case for it was to be able to automatically redeploy my website when pushing on the master branch.

Codeberg does not have a CI/CD system out of the box like GitHub does with GitHub Actions.

They do host a Woodpecker CI instance, but you need to request access to it. And depending on your resource usage, they might not accept your request.
For deploying my website, this wouldn’t have been a problem. But I may need more resources for my future projects, so I decided to already deploy my own CI server so that I don’t have to bother with it later.

Prerequisites

  • A Linux server with at least 2GB of memory
  • Optionally, a Cloudflare account with a registered domain name

Setup

To host my own Woodpecker CI instance, I first had to boot up a server from a cloud provider. I went with UpCloud for this one (my free trial was still ongoing), but any provider would work.
I initially chose a server with 1GB of memory, but I later found out that wasn’t enough to run my pipeline. So I instead opted for a server with 2GB of memory, which ended up being enough. I decided to run it on Debian 13.

The first thing I do everytime I get a new server is to add it to my tailnet (Tailscale network) so that I don’t have to mess around with SSH keys, but this is completely optional.

I decided to run my Woodpecker CI instance using Docker Compose. This instance will be accessible behind a Traefik reverse-proxy that will handle the HTTPS certificates for me.

First, I had to install Docker on my server following the instructions from the official Docker documentation. Then, I created new directories and files as such:

Terminal window
~$ tree -a
[...]
|-- traefik
| |-- .env # Contains the Traefik env variables
| |-- certs # Will contain the HTTPS certificates
| |-- compose.yml # Contains the Traefik deployment
| `-- config
| `-- traefik.yml # Contains the Traefik configuration
`-- woodpecker
|-- .env # Contains the Woodpecker CI env variables
`-- compose.yml # Contains the Woodpecker CI deployment

I also created a Docker network so both deployments can communicate (and in general to expose services to the Traefik instance):

Terminal window
~$ docker network create web

Traefik Configuration

I set up the Traefik deployment as such:

traefik/compose.yml
services:
traefik:
container_name: traefik
image: traefik:v3.4
ports:
- "80:80"
- "443:443"
environment:
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config/:/etc/traefik/:ro
- ./certs/:/var/traefik/certs/:rw
networks:
- web
restart: unless-stopped
networks:
web:
external: true

I won’t go into too much details, but here’s some explanation:

  • I open the ports 80 and 443 for HTTP and HTTPS. You will see later, but I will configure Traefik such that HTTP requests are automatically redirected into HTTPS.
  • I set the CF_DNS_API_TOKEN environment variable. This is done so that Traefik can automatically request HTTPS servers through Cloudflare. This variable will be defined in the .env file.
  • I mount the following volumes:
    • /var/run/docker.sock so that Traefik can automatically detect new containers;
    • ./config/ so that Traefik can read and use my configuration file;
    • ./certs/ so that Traefik can read and update the HTTPS certificates.
  • I connect the container to my web network created just before.

Then, I have to configure Traefik:

traefik/config/traefik.yml
global:
checkNewVersion: false
sendAnonymousUsage: false
log: { }
accessLog: { }
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
certificatesResolvers:
cloudflare:
acme:
email: <my contact email>
storage: /var/traefik/certs/cloudflare-acme.json
caServer: "https://acme-v02.api.letsencrypt.org/directory"
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
- "8.8.8.8:53"
providers:
docker:
exposedByDefault: false

Here, you can see how I declare the two entypoints, one called web for HTTP and one called websecure for HTTPS, and set up the redirection from the web endpoint to the websecure endpoint.

You can also see how I’ve declared the Cloudflare certificate resolver, and how it stores the certificates in the certs/ folder.

Finally, I configure the docker provider with exposedByDefault: false so that I have to manually declare my Docker containers to be detected by Traefik.

To generate the CF_DNS_API_TOKEN, I checked the Lego documentation. If you are following this guide but use another domain name provider, make sure to check the Lego documentation as well.

traefik/.env
CF_DNS_API_TOKEN=<my token>

With a docker compose up -d, I got my Traefik instance up and running.

Woodpecker CI Configuration

Alright, now let’s get started on Woodpecker CI:

woodpecker/compose.yml
services:
woodpecker-server:
container_name: woodpecker-server
image: woodpeckerci/woodpecker-server:v3
volumes:
- woodpecker-server-data:/var/lib/woodpecker/
environment:
- WOODPECKER_OPEN=false
- WOODPECKER_ADMIN=skyecodes
- WOODPECKER_HOST=https://woodpecker.skye.codes
- WOODPECKER_FORGEJO=true
- WOODPECKER_FORGEJO_URL=https://codeberg.org
- WOODPECKER_FORGEJO_CLIENT=${WOODPECKER_FORGEJO_CLIENT}
- WOODPECKER_FORGEJO_SECRET=${WOODPECKER_FORGEJO_SECRET}
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
networks:
- web
- default
labels:
traefik.enable: true
traefik.docker.network: web
traefik.http.services.woodpecker.loadbalancer.server.port: 8000
traefik.http.routers.woodpecker.entrypoints: websecure
traefik.http.routers.woodpecker.rule: Host(`woodpecker.skye.codes`)
traefik.http.routers.woodpecker.tls: true
traefik.http.routers.woodpecker.tls.certresolver: cloudflare
traefik.http.routers.woodpecker.service: woodpecker
woodpecker-agent:
container_name: woodpecker-agent
image: woodpeckerci/woodpecker-agent:v3
command: agent
restart: always
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=${WOODPECKER_AGENT_SECRET}
networks:
- default
volumes:
woodpecker-server-data:
woodpecker-agent-config:
networks:
web:
external: true

This one is a bit more complicated because Woodpecker CI is split into two services: the server and the agent.

  • The server provides the web UI and handles the external communication with Codeberg
    • It has a volume to retain its data after a rebuild.
    • It has a bunch of environment variables to connect to Codeberg, some of which are in the .env file.
    • It connects to the web network, and the port 8000 is exposed through Traefik’s websecure endpoint.
      • It has a custom domain for which the HTTPS certificate is resolved through Traefik and Cloudflare.
  • The agent runs the pipelines and communicates with the server.
    • It depends on the server container and connects to it through port 9000.
    • It has a volume to retain its config after a rebuild and also has access to the Docker socket so that it can boot up new containers to run pipelines.
  • The server and the agent both share a secret (it can be anything)—in my case it’s a randomly generated string.

For the WOODPECKER_FORGEJO_CLIENT and WOODPECKER_FORGEJO_SECRET variables, I needed to register the application in my Codeberg account settings. I followed the instructions from the Woodpecker CI docs to generate them.

woodpecker/.env
WOODPECKER_FORGEJO_CLIENT=<client token from Codeberg>
WOODPECKER_FORGEJO_SECRET=<secret token from Codeberg>
WOODPECKER_AGENT_SECRET=<generated string>

I also had to add a record to my DNS zone on Cloudflare so that the woodpecker.skye.codes domain resolves to the IP address of my server.

As a sidenote, since Codeberg needs to reach the Woodpecker CI instance, it must be publicly reachable.

The only thing left to do is to start it with docker compose up -d!

Running my first pipeline

The first step was to connect to the Woodpecker CI instance by browsing to https://woodpecker.skye.codes. There, I could log into my Codeberg account and then connect my website repository to the instance. Adding the repository to Woodpecker CI created a webhook on the Codeberg repository that triggers and calls my Woodpecker CI instance everytime I push to the repository.

Finally, I pushed a pipeline script to my repository:

.woodpecker/deploy.yml
when:
- event: push
branch: master
steps:
- name: Build and deploy
image: node
environment:
CLOUDFLARE_API_TOKEN:
from_secret: CLOUDFLARE_API_TOKEN
WRANGLER_SEND_METRICS: false
commands:
- npm install
- npx astro build
- npx wrangler deploy

Woodpecker CI implicitly pulls the code from the repository, so there’s no need to add this step at the start.
The commands in the pipeline are basic: they install the dependencies, build the website and deploy it.

I had to generate another Cloudflare token to automatically deploy the Worker, and add it to the Woodpecker CI as a repository secret. After that was done, and my code pushed, the website got automatically deployed! The only thing left for me to do was to edit the Worker settings on the Cloudflare console to use my domain name for the website.

Et voilà, my CI is now fully set up! The next time I need to run a pipeline, I just have to connect the repository to my Woodpecker CI instance and push my pipeline configuration.