Use case · CI/CD

Woodpecker CI self hosted on a dedicated server

To run Woodpecker CI self hosted you need two containers, a server for the web UI, API and webhooks and an agent that runs each pipeline step as a Docker container, plus an OAuth app on the forge your code lives on. Both fit on one machine with Docker Compose; the agent mounts the host's Docker socket, so the box that runs your builds should be a box you trust nothing else to.

See the machines

What a Woodpecker install consists of

Woodpecker splits into a server and one or more agents. The server serves the UI and API, takes the forge's webhooks and parses pipeline YAML; agents connect over gRPC and run workflows through a backend, Docker by default.

ComponentJobListens onState it keeps
woodpecker-serverUI, API, webhooks, scheduling:8000 HTTP, :9000 gRPC/var/lib/woodpecker (SQLite by default)
woodpecker-agentRuns steps as containershealthcheck :3000/etc/woodpecker/agent.conf (its agent ID)
Forge OAuth appLogins, repo access, status reportson the forgeclient ID and secret in your .env

The project's README.md puts idle use at around 100 MB of RAM for the server and 30 MB for the agent; the build containers are what size the machine.

Sizing the machine for builds

An agent runs one workflow at a time unless WOODPECKER_MAX_WORKFLOWS says otherwise, so parallelism is a setting you choose against cores, memory and disk. The table is our guidance, not a figure from the Woodpecker docs.

Build load (our guidance)What it leans onLine class on the range
A few repos, short test runsClock speed, NVMeDesktop platform (Core i5-9500, 32 GB, NVMe) or the Xeon E3-1585L v5 cartridge
Several teams, parallel workflowsCores and memoryDual-socket Xeon (E5-2630L v3, Gold 6130)
Native arm64 imagesAn ARM hostAmpere Altra, built to order

The dedicated server range lists every line with its processor, memory, port and cities; it names the desktop platform for CI runners and the dual-socket machines for many services on one box. Woodpecker publishes server and agent images for linux/amd64 and linux/arm64, which is why an ARM dedicated server is a real option when your images target arm64. Racked lines hand over in about 30 minutes once payment is approved. The service page gives 4 to 24 hours for a built-to-order line; the Altra is one, so confirm its lead time in chat before you plan around it.

The agent never removes images; the docs leave cleanup to the host and warn their example commands are destructive. Size the disk for the image cache.

The Docker socket is root on this host

The docs mount /var/run/docker.sock into the agent because it starts every step as a container, and the migration notes say the agent image must stay rootful for that. Our note, not a line from the docs: whatever can talk to that socket can start a container with the host's filesystem mounted, so socket access is root access. What the docs put between a pipeline and that power:

  • Trusted is a per-repo setting only server admins can switch on. It unlocks escalated capabilities such as host volumes, and the volumes page shows mounting the Docker socket itself into a step.
  • Privileged mode in pipeline YAML works only on trusted repos, and the docs say to use it only in private environments.
  • WOODPECKER_PLUGINS_PRIVILEGED lists plugin images that run privileged. Since 3.0 no plugin gets it by default; pin exact tags, as the docs advise.
  • Approvals: by default, pipelines from forks wait for approval. It can be widened to every pull request or every event.
  • WOODPECKER_DISABLE_USER_AGENT_REGISTRATION: by default repo admins may register their own agents; the docs suggest turning that off if you keep global secrets.

Our reading: a repo you mark trusted, or a plugin you allow privileged, can own the machine, and even untrusted steps run arbitrary code in containers that share its kernel. So give CI its own host with nothing else on it, not the server that holds your forge or production data.

Before the install: DNS and the OAuth app

  1. DNS first: give the CI hostname (ci.example.com here) an A record and an AAAA record pointing at this server, and wait until both resolve. Caddy asks for its certificate the moment it loads the site.
  2. On GitHub, create an OAuth App (not a GitHub App; the docs warn that one does not work correctly yet). Homepage URL is https://ci.example.com, callback URL is https://ci.example.com/authorize.
  3. Generate its client secret. It goes into .env in the next section, typed into an editor rather than a command line, where it would land in shell history.

With Forgejo or Gitea the app is made under the user's settings (or site-wide by an instance admin), the callback must be the Woodpecker address plus /authorize, matched exactly, and the variables become WOODPECKER_FORGEJO=true or WOODPECKER_GITEA=true with _URL, _CLIENT and _SECRET. If the forge runs on the same host, its webhooks need ALLOWED_HOST_LIST=external,loopback under [webhook] in app.ini, and the agent should join the forge's Docker network. Our Forgejo and Gitea pages cover running the forge itself.

Server and agent with Docker Compose

Start from Ubuntu 24.04, whose archive has the caddy package used below (22.04's does not), and install Docker Engine 28 or newer, Compose included, following Docker's instructions; older engines let neighbours on the same segment reach ports published on 127.0.0.1 (moby#45610). The first block sets up the firewall, writes the compose file into /opt/woodpecker and creates the secrets file:

# 1. Firewall: IPv6 rules on first, SSH allowed before enabling
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
ufw allow 80,443/tcp

# 2. The compose file, then the secrets file at mode 600 before it gets content
install -d -m 700 /opt/woodpecker
cat > /opt/woodpecker/compose.yaml <<'EOF'
services:
  woodpecker-server:
    image: woodpeckerci/woodpecker-server:v3
    restart: always
    ports:
      - 127.0.0.1:8000:8000
    volumes:
      - woodpecker-server-data:/var/lib/woodpecker/
    env_file: .env
    environment:
      - WOODPECKER_OPEN=false
      - WOODPECKER_GITHUB=true

  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}
      - WOODPECKER_MAX_WORKFLOWS=4

volumes:
  woodpecker-server-data:
    name: woodpecker-server-data
  woodpecker-agent-config:
    name: woodpecker-agent-config
EOF
install -m 600 /dev/null /opt/woodpecker/.env
echo "WOODPECKER_AGENT_SECRET=$(openssl rand -hex 32)" >> /opt/woodpecker/.env

It follows the docs' Compose example with these changes of ours: the UI port is bound to 127.0.0.1, WOODPECKER_OPEN is pinned to false, the server reads .env and restarts on its own, the agent runs four workflows at once (pick a number your cores and memory carry), and the volumes get fixed names so backups can find them. Next, on its own, run nano /opt/woodpecker/.env and add four lines: WOODPECKER_HOST (scheme and host, no trailing slash), WOODPECKER_ADMIN (your GitHub login), WOODPECKER_GITHUB_CLIENT and WOODPECKER_GITHUB_SECRET. Save, leave the editor, then paste the gated block that starts the stack and only then publishes it:

# 3. Each step runs only if the one before it succeeded
grep -Eq '^WOODPECKER_ADMIN=.+' /opt/woodpecker/.env \
  && 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

Without --force, ufw stops to ask "Command may disrupt existing ssh connections. Proceed with operation (y|n)?", and in a pasted block that prompt swallows the next line. A port mapping with no address is published on 0.0.0.0 and [::] and skips ufw's rules on IPv4 (on IPv6 too when Docker manages IPv6), which is why the UI stays on 127.0.0.1 and only Caddy, an ordinary host process, faces the internet through 80 and 443. The last line prints published only when every step worked. If it does not appear, the step that failed (no admin set, the start, a port bound anywhere but 127.0.0.1, the caddy install) stopped everything after it; when the port was not on 127.0.0.1, the block also takes the stack back down, so nothing stays public. Fix that step and paste the block again.

Who can log in, and the first admin

Registration is closed by default (WOODPECKER_OPEN=false); with it open, anyone with an account on the forge can log in, and the docs' own Compose example sets it to true. There is no default password to change; what must be in place before Caddy goes live is sign-up closed and WOODPECKER_ADMIN naming you. Login only works through the public address, since the forge returns the browser to WOODPECKER_HOST.

Accounts named in WOODPECKER_ADMIN can still create themselves on first login while sign-up is closed (we read that in server/api/login.go, not the docs). Add everyone else with woodpecker-cli admin user add, or open sign-up and filter by WOODPECKER_ORGS.

Values in .env reach the containers as environment variables, which docker inspect shows to anyone with Docker access on the host. The secrets this setup uses, the agent secret, the GitHub client ID and secret, and the database datasource if you add one, each have a _FILE variant that reads the value from a file instead.

SQLite, Postgres and what to back up

SQLite needs no setup and lives in /var/lib/woodpecker; the docs call it the most important thing to persist, since it holds all users and repository information, and pipeline logs go into the database too unless WOODPECKER_LOG_STORE says otherwise. For larger instances the docs recommend Postgres (11 or newer) or MariaDB: create the database yourself, then set WOODPECKER_DATABASE_DRIVER=postgres and a postgres:// string in WOODPECKER_DATABASE_DATASOURCE. Woodpecker does no backups and no archiving of old data, and the docs warn logs grow the database considerably.

The script archives all the install keeps: the compose folder with .env, the server volume and the agent volume. It stops the server so the SQLite file is consistent and restarts it even if a step fails; run it in a quiet hour, from cron as 30 3 * * * bash /usr/local/sbin/woodpecker-backup.sh >> /var/log/woodpecker-backup.log 2>&1, and copy the archives off the machine yourself. On Postgres, add a pg_dump -Fc -f into the same folder.

# /usr/local/sbin/woodpecker-backup.sh
# run as root: bash /usr/local/sbin/woodpecker-backup.sh
set -euo pipefail
umask 077
C=/opt/woodpecker/compose.yaml
B=/var/backups/woodpecker
mkdir -p -m 700 "$B"
F="$B/woodpecker-$(date +%F-%H%M).tar.gz"
docker compose -f "$C" stop woodpecker-server
trap 'docker compose -f "$C" start woodpecker-server' EXIT
tar -czf "$F" -C / opt/woodpecker \
  var/lib/docker/volumes/woodpecker-server-data \
  var/lib/docker/volumes/woodpecker-agent-config
tar -tzf "$F" > /dev/null
echo "ok $F"

Restoring over a running install, as root, in one shell:

  1. F=/var/backups/woodpecker/woodpecker-YYYY-MM-DD-HHMM.tar.gz; tar -tzf "$F" > /dev/null && echo ok, and go no further unless it prints ok.
  2. docker compose -f /opt/woodpecker/compose.yaml down (named volumes survive this).
  3. Set the current state aside and check that copy: A=/var/backups/woodpecker/aside-$(date +%s).tar.gz; tar -czf "$A" -C / opt/woodpecker var/lib/docker/volumes/woodpecker-server-data var/lib/docker/volumes/woodpecker-agent-config && tar -tzf "$A" > /dev/null && echo ok. Stop unless it prints ok.
  4. Empty both volumes, which also clears any stale SQLite -wal and -shm files: find /var/lib/docker/volumes/woodpecker-server-data/_data /var/lib/docker/volumes/woodpecker-agent-config/_data -mindepth 1 -delete.
  5. tar -xzf "$F" --numeric-owner -C / puts back the files with their original owners.
  6. docker compose -f /opt/woodpecker/compose.yaml up -d, log in and run a pipeline. Only when both work, rm "$A".

Updates and the licence

There is no latest tag, by design: v3 follows the current major line, and an exact vX.Y.Z holds still until you change it. New versions migrate the database on start, so take a backup first, read the migrations page before a major jump, then run docker compose -f /opt/woodpecker/compose.yaml pull and docker compose -f /opt/woodpecker/compose.yaml up -d. A rollback means the older image plus the pre-upgrade archive, restored as above.

Woodpecker is Apache-2.0 (LICENSE, README.md; the docs folder is CC BY-SA 4.0). Our general reading, not legal advice: it is permissive, so no duty to publish source; if you pass copies on, modified or not, keep the copyright notices and licence text and mark files you changed. Prefer CI inside the forge? See our GitHub Actions runner page.

Questions

Does Woodpecker keep its own user accounts?

No. It signs users in through the forge with OAuth2 and works with their permissions there. A forge user with admin rights on a repo is also its admin in Woodpecker.

Can one instance serve GitHub and a Forgejo at once?

Only one forge is set through environment variables; more can be added in the admin settings, but the docs mark multiple forges as experimental and say not to use them in public or untrusted setups. WOODPECKER_ADMIN matches login names on every connected forge.

Can agents run on other machines?

Yes. Each agent needs the server's gRPC port and the shared WOODPECKER_AGENT_SECRET. Across the internet the docs say to turn on TLS for gRPC (WOODPECKER_GRPC_SECURE=true on the agent), and their Caddy example exposes gRPC on its own hostname.

Why not the latest image tag?

Woodpecker does not publish one, to prevent accidental major upgrades. Use v3, a minor tag or an exact release. The plain tags are scratch images with no shell; -alpine variants of each tag are published as well.

A firewall rule cut off SSH. Now what?

Racked single-, dual- and quad-socket servers carry an out-of-band console, and so do the m510 and m710x cartridges; other cartridges get power control only, and the service page names none for the desktop platform. A fresh OS image from the control panel is the fallback on every line.

Put CI on a machine of its own

Pick a line with the cores, memory and disk your parallel builds need; root, an unmetered port and monthly terms come with every one.

See the machines

Related on Server Room

Other use cases