Codeberg and Forgejo Actions: A Complete Guide for Self-Hosted CI/CD
Table of Contents
- Introduction
- Getting Started with tea CLI
- Managing Secrets
- Forgejo Actions: Not Quite GitHub Actions
- Action URL Resolution
- Practical CI/CD Example
- Common Pitfalls and Solutions
- Conclusion
Introduction
Codeberg is a free-software Git hosting platform running on Forgejo. Hosted in Europe, subject to GDPR, and governed by a non-profit, it answers a simple question: what if your code hosting wasn't at the mercy of a single corporation's pricing whims?
We're not here to sell you on Codeberg. The runner images are minimal. Some GitHub Actions don't have Forgejo equivalents. But for teams wanting self-hosted CI/CD without vendor lock-in, the trade-offs are worth understanding.
Forgejo Actions look almost like GitHub Actions. "Almost" hides some sharp edges. This guide covers the entire workflow, from CLI setup to production pipelines.
Getting Started with tea CLI
tea is the official CLI for Gitea and Forgejo. It handles repo management, issue tracking, secrets, and more from the terminal.
Installation
| Method | Command |
|---|---|
| Homebrew | brew install tea |
| Chocolatey | choco install gitea.tea |
| Scoop | scoop install tea |
| Go | go install code.gitea.io/tea@latest |
| AUR | pacman -S tea |
| Binary | dl.gitea.com/tea |
Authentication
# Add your Codeberg login (use a personal access token from /user/settings/applications)
tea login add --name codeberg --url https://codeberg.org --token <TOKEN>
tea login default codeberg # set as default
tea whoami # verify it works
Common Operations
tea repos list # list your repos
tea repo create --name my-project --private # create a new repo
tea issues list # check issues
tea pr create --title "Fix auth" # open a pull request
tea open # open repo in browser
Use --repo owner/repo and --login codeberg when you need to target a specific repository or instance.
Managing Secrets
Secrets are the backbone of any CI/CD pipeline. You need them for deploy keys, API tokens, and SSH credentials.
Via CLI
tea actions secrets list # view existing secrets
tea actions secrets create DEPLOY_KEY # create (prompts for value)
tea actions secrets delete DEPLOY_KEY # remove a secret
Via Web UI
Navigate to /{owner}/{repo}/settings/actions/secrets in your browser.
Via API
For anything the CLI doesn't cover:
tea api /repos/{owner}/{repo}/actions/secrets \
--method PUT \
--body '{"name":"SSH_PRIVATE_KEY","data":"<base64-encoded-value>"}'
Setting Up an SSH Deploy Key
Here's a practical example. Generate a key pair, add the public key to your server's authorized_keys, then store the private key as a secret:
# Generate a deploy key
ssh-keygen -t ed25519 -f deploy_key -N ""
# Store the private key as a Codeberg secret
tea actions secrets create SSH_PRIVATE_KEY
# Paste the contents of deploy_key when prompted
# Add the public key to your target server
ssh-copy-id -i deploy_key.pub user@your-server.com
In workflows, reference secrets with ${{ secrets.SSH_PRIVATE_KEY }}. Forgejo also auto-injects useful variables: FORGEJO_TOKEN, FORGEJO_SERVER_URL, FORGEJO_REPOSITORY, FORGEJO_REF, and FORGEJO_SHA.
Forgejo Actions: Not Quite GitHub Actions
Forgejo Actions follow a "familiarity over compatibility" philosophy. The YAML looks like GitHub Actions. The behavior doesn't always match. This is the section to bookmark.
Key Differences
| Aspect | GitHub Actions | Forgejo Actions |
|---|---|---|
Job-level permissions |
Supported | Ignored (use step-level) |
Job-level timeout-minutes |
Supported | Ignored (use step-level) |
Job-level continue-on-error |
Supported | Ignored (use step-level) |
| Default runner image | ubuntu-latest (full suite) | Debian bookworm + Node.js (minimal) |
| Context variable | github.* only |
forgejo.*, forge.*, github.* (all aliased) |
| Action registry | GitHub Marketplace | https://data.forgejo.org/ |
| LXC containers | Not available | Supported |
The job-level vs. step-level distinction is the biggest migration headache. Set timeout-minutes: 10 at the job level and it silently does nothing. Move it to each step. Same for permissions and continue-on-error.
The runner image is the second surprise. GitHub's ubuntu-latest ships with Docker, SSH, and build tools. Forgejo's default runner is a lean Debian image with Node.js. Need SSH? Install it. Need Docker? Install it. Smaller images, but more verbose workflows.
Action URL Resolution
On GitHub, actions/checkout@v4 just works. On Forgejo, short-form resolution depends on instance configuration. Always use fully-qualified URLs to avoid surprises.
Common Action Mappings
| GitHub Shorthand | Forgejo URL |
|---|---|
actions/checkout@v4 |
https://data.forgejo.org/actions/checkout@v4 |
actions/setup-node@v4 |
https://data.forgejo.org/actions/setup-node@v4 |
actions/setup-go@v5 |
https://data.forgejo.org/actions/setup-go@v5 |
actions/upload-artifact@v4 |
https://data.forgejo.org/actions/upload-artifact@v4 |
actions/download-artifact@v4 |
https://data.forgejo.org/actions/download-artifact@v4 |
actions/cache@v4 |
https://data.forgejo.org/actions/cache@v4 |
For actions that only exist on GitHub, reference them directly:
- uses: https://github.com/appleboy/ssh-action@v1
Note: the runner must reach github.com for this to work. In air-gapped setups, mirror or vendor those actions.
Practical CI/CD Example
Here's a complete deployment workflow that builds a Node.js app and deploys it via SSH:
# .forgejo/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: docker # Forgejo uses 'docker' label, not 'ubuntu-latest'
steps:
# Step-level timeout matters — job-level is ignored
- name: Checkout
timeout-minutes: 5
uses: https://data.forgejo.org/actions/checkout@v4
- name: Setup Node.js
timeout-minutes: 5
uses: https://data.forgejo.org/actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
timeout-minutes: 10
run: npm ci
- name: Build
timeout-minutes: 5
run: npm run build
# The runner is minimal — install SSH if the action needs it
- name: Install SSH client
timeout-minutes: 2
run: apt-get update && apt-get install -y openssh-client
# appleboy/ssh-action has no Forgejo mirror, use GitHub directly
- name: Deploy via SSH
timeout-minutes: 5
uses: https://github.com/appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/app
git pull origin main
npm ci --production
pm2 restart app
Notice the Forgejo-specific choices: the docker runner label, fully-qualified action URLs, and the explicit SSH client installation. Skip any of these and your pipeline breaks.
Common Pitfalls and Solutions
| Problem | Cause | Fix |
|---|---|---|
timeout-minutes ignored |
Set at job level | Move to step level |
| Action not found | Short-form URL without registry config | Use fully-qualified URL |
| Runner missing SSH, Docker, etc. | Default image is minimal | apt-get install -y <package> in a step |
| Actions tab missing entirely | Not enabled for the repo | Settings → Units → enable Actions |
| Secret not accessible in workflow | Wrong scope (env vs. repo) | Ensure it's a repo or org secret |
| GitHub-only action fails in air-gapped env | Runner can't reach github.com | Mirror the action to your Forgejo instance |
github.* context not working |
Old Forgejo version | Update, or use forgejo.* / forge.* aliases |
Conclusion
Codeberg makes sense when you need digital sovereignty, GDPR compliance, or freedom from platform lock-in. The trade-off is a smaller ecosystem and more verbose workflow files.
The key takeaways: always use fully-qualified action URLs, put timeouts and permissions at the step level, and expect a minimal runner image that needs explicit tool installation. Once you internalize those three rules, migrating from GitHub Actions to Forgejo Actions is straightforward.
If your team already knows GitHub Actions, the learning curve is measured in hours, not days. The tea CLI fills the gap that gh leaves behind, and the workflow syntax is close enough that muscle memory mostly transfers. Just watch out for those job-level gotchas.