LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Forgejo and Gitea: Self-Hosted Git

forgejogiteagitself-hostedci-cdhomelabdevops

Running your own Git server was, for years, either GitLab — which is excellent but heavy — or a bare Git daemon barely above git init --bare. Gogs changed that in 2015 by shipping a single Go binary that was genuinely pleasant to use, and Gitea improved on it further. Today, Forgejo (a community fork of Gitea) sits at the top of that lineage: a self-contained Git platform with CI/CD, a container registry, organization permissions, webhooks, and experimental federation support, all in a binary that comfortably runs on a Raspberry Pi 4.

This guide covers everything you need to run Forgejo in production: deployment with Docker Compose and PostgreSQL, SSH and HTTPS access through a reverse proxy, Forgejo Actions for CI/CD with self-hosted runners, the built-in container registry, organization and team permissions, webhooks, migrating from GitHub, and a solid backup strategy. Where Gitea differs meaningfully, I’ll call it out. The two projects share enough DNA that most of this guide applies to both, but the divergence is real and growing — you should understand it before committing to a deployment.


Forgejo vs Gitea: The Fork History and Why It Matters

Understanding why there are two projects requires a brief history lesson, because the governance situation shapes the long-term trajectory of both platforms in ways that matter for a system you will run for years.

Gogs to Gitea (2016). Gogs was created by Unknwon (Joe Chen) as a lightweight self-hosted Git platform written in Go. It was fast, simple, and gained a large following quickly. In late 2016, a group of contributors — frustrated by slow issue response times and a single-maintainer bottleneck — forked the project to create Gitea. Gitea opened up the contribution process, shipped features faster, and became the de facto lightweight Git platform for homelabbers and small teams through the late 2010s and early 2020s.

The 2022 governance crisis. In October 2022, the Gitea domain and trademark were quietly transferred to a newly formed for-profit entity, Gitea Ltd, without a community vote or prior public discussion. The Gitea Ltd founders are the project’s primary maintainers, and most contributors only learned about the corporate structure after the fact. An open letter from the community requested transparency and a governance restructuring. Gitea Ltd acknowledged the letter but declined to make substantive changes to the ownership arrangement.

This is not an abstract complaint about corporate involvement. The specific concern is that trademark and domain ownership give Gitea Ltd unilateral authority to define what software is “officially” Gitea, and to change the project’s direction in ways that serve commercial interests over community ones. For a security-sensitive tool that stores your source code, that governance structure is a meaningful risk. The community’s response was to fork.

Forgejo (October 2022 onward). Forgejo was created immediately after the governance disclosure, operating under the umbrella of Codeberg e.V., a German non-profit dedicated to free and open-source software. Forgejo initially tracked Gitea closely, functioning as a soft fork with minor changes. In early 2024, Forgejo officially became a hard fork, with its codebase diverging meaningfully from Gitea. In August 2024, Forgejo changed its license from MIT to GPL v3+. Versions v9.0 and later are GPL; earlier releases were MIT.

The license change matters practically: Gitea remains MIT-licensed, meaning anyone can build proprietary products on top of it without publishing modifications. Forgejo’s GPL license requires derivative works to also be open-source. For self-hosters and infrastructure teams this is largely irrelevant day-to-day, but it signals a philosophical commitment to preventing the same governance pattern from recurring — you cannot quietly build a proprietary product on GPLv3 code and then claim community ownership.

Where things stand today. As of mid-2026, Forgejo is at version 15.0.2 (an LTS release, supported through July 2027) and Gitea is at 1.26.2. The feature sets have meaningfully diverged over the two years since the hard fork:

Feature Forgejo 15.x Gitea 1.26.x
License GPL v3+ MIT
Governance Codeberg e.V. non-profit Gitea Ltd (for-profit)
Actions / CI-CD Forgejo Actions (GH Actions-compatible) Gitea Actions
Forge federation (ActivityPub/ForgeFed) Experimental / in progress No active development
Container / package registry Yes Yes
Repository-specific access tokens Yes (v15+) Not in current release
LXC container builds for runners Yes No
Runner registration via web UI Yes (v15+) Token-based CLI only
Database support SQLite, PostgreSQL, MySQL SQLite, PostgreSQL, MySQL
API compatibility High (Gitea API clients work) n/a
Image registry data.forgejo.org/forgejo/forgejo gitea/gitea (Docker Hub)

Federation status. Federated repository stars — the ability to star a repository on a remote Forgejo instance from your own — work experimentally. The broader ForgeFed vision (opening PRs and filing issues across instances) is under active NLnet-funded development. The Forgejo project explicitly warns that federation components are not yet stable and may require breaking changes that would invalidate your instance’s ActivityPub identity. Do not build workflows that depend on federation yet. It is worth watching closely.

Which should you choose? For the majority of new deployments in 2026, Forgejo is the stronger default. The non-profit governance removes the conflict-of-interest concern, the GPL license protects the community’s work, and Forgejo is shipping features that Gitea is not actively pursuing. The API compatibility is high enough that tooling designed for Gitea works against Forgejo with minimal changes. Gitea’s MIT license and the numerical advantage in public GitHub stars (reflecting accumulated years before the fork) mean some enterprise tooling still assumes Gitea — check any integrations you depend on before committing.


Deployment with Docker Compose

Forgejo ships as a single Docker image at data.forgejo.org/forgejo/forgejo. The Gitea equivalent is gitea/gitea on Docker Hub.

Do not use SQLite for anything beyond a personal single-user instance or a temporary test. SQLite holds up fine for light use but does not handle concurrent writes gracefully, backup and restore require matching SQLite binary versions, and the PostgreSQL migration path later is more painful than starting right. PostgreSQL is the correct choice for any team deployment or any instance that runs CI workloads.

Here is a complete, production-oriented docker-compose.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
services:
  forgejo:
    image: data.forgejo.org/forgejo/forgejo:15
    container_name: forgejo
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - FORGEJO__database__DB_TYPE=postgres
      - FORGEJO__database__HOST=db:5432
      - FORGEJO__database__NAME=forgejo
      - FORGEJO__database__USER=forgejo
      - FORGEJO__database__PASSWD=${POSTGRES_PASSWORD}
      - FORGEJO__server__DOMAIN=git.example.com
      - FORGEJO__server__SSH_DOMAIN=git.example.com
      - FORGEJO__server__ROOT_URL=https://git.example.com/
      - FORGEJO__server__HTTP_PORT=3000
      - FORGEJO__server__SSH_PORT=2222
      - FORGEJO__server__START_SSH_SERVER=true
      - FORGEJO__service__DISABLE_REGISTRATION=true
      - FORGEJO__service__REQUIRE_SIGNIN_VIEW=false
      - FORGEJO__log__LEVEL=Info
      - FORGEJO__security__INSTALL_LOCK=true
    ports:
      - "127.0.0.1:3000:3000"
      - "2222:2222"
    volumes:
      - forgejo_data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    networks:
      - forgejo_net

  db:
    image: postgres:16-alpine
    container_name: forgejo_db
    restart: unless-stopped
    environment:
      - POSTGRES_USER=forgejo
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=forgejo
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - forgejo_net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U forgejo"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  forgejo_data:
  postgres_data:

networks:
  forgejo_net:
    driver: bridge

Put POSTGRES_PASSWORD=<strong_random_password> in a .env file alongside docker-compose.yml and keep it out of version control. A .env.example with placeholder values is fine to commit.

Key configuration decisions explained.

FORGEJO__security__INSTALL_LOCK=true skips the first-run installation wizard. Without this flag, visiting your Forgejo URL before configuration completes presents an installer page that anyone on the network can use to set the admin password. Set it from the start and create the initial admin account via CLI after the first startup:

1
2
3
4
5
docker exec -it forgejo forgejo admin user create \
  --username admin \
  --password <strong-password> \
  --email admin@example.com \
  --admin

FORGEJO__server__START_SSH_SERVER=true enables Forgejo’s built-in SSH server rather than relying on the host’s sshd. This is essential for rootless containers or any container that cannot bind to port 22. The trade-off is that SSH clone URLs use port 2222 instead of the conventional 22. You can work around the non-standard port: NAT port 22 on the host firewall down to 2222 on the container, or configure ~/.ssh/config on developer machines to use the correct port automatically.

ports: - "127.0.0.1:3000:3000" — binding the HTTP port to loopback only ensures the application is not directly reachable from the network. All HTTP traffic should arrive through your reverse proxy. Without this, the application is exposed on all interfaces.

The app.ini configuration file. Forgejo’s persistent configuration lives at /data/gitea/conf/app.ini inside the container, mapped via the forgejo_data volume. Environment variables in the form FORGEJO__<SECTION>__<KEY> override corresponding app.ini settings at startup — this is the preferred approach for containerized deployments because it keeps secrets out of committed files and makes configuration auditable in your deployment repo. Key sections of app.ini:

  • [server] — domain, ports, ROOT_URL, SSH configuration
  • [database] — database type, host, credentials
  • [service] — registration, email confirmation, user limits
  • [mailer] — SMTP settings for notifications and user verification
  • [log] — log level, output destination
  • [security] — secret key, INSTALL_LOCK, password requirements

If you need a setting that has no environment variable equivalent, edit app.ini directly. Changes require a container restart.

Resource usage. Forgejo is genuinely lightweight software. An idle Forgejo instance with PostgreSQL uses roughly 150–250 MB of RAM total. A Raspberry Pi 4 with 4 GB of RAM can comfortably host Forgejo with several active repositories and moderate CI workloads — not something you can say about GitLab.


SSH Access

SSH is how developers push and pull in daily use. HTTPS works fine, but SSH avoids credential management and is faster for frequent operations. Forgejo’s built-in SSH server handles this without needing to touch the host’s sshd configuration.

How it works under the hood. When a user adds an SSH public key through the UI (User Settings → SSH/GPG Keys → Add Key), Forgejo stores it in the database and writes it to /data/git/.ssh/authorized_keys. Each entry in that file looks like:

command="/usr/local/bin/forgejo serv key-42 --config=/data/gitea/conf/app.ini",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty <public key here>

The command= option means that regardless of what command the SSH client tries to run, the server executes forgejo serv. That binary interprets the Git operation (clone, fetch, push), looks up the key ID to identify the user, checks repository permissions, and hands off to Git if access is permitted. This is the standard pattern for self-hosted Git servers — Gitlab uses the same mechanism.

Adding your key. Log into the web UI, go to User Settings (top-right avatar menu), select SSH/GPG Keys, and paste your public key (~/.ssh/id_ed25519.pub or equivalent). Forgejo validates the key format and rejects duplicates.

Testing your connection:

1
ssh -T git@git.example.com -p 2222

A successful response:

Hi alice! You've successfully authenticated, but Forgejo does not provide shell access.

Clone URL format:

1
git clone ssh://git@git.example.com:2222/alice/my-repo.git

The ssh:// scheme with an explicit port is required when using a non-standard port. The SCP-style shorthand (git@host:user/repo.git) does not support port specification inline — use ~/.ssh/config instead.

SSH config shortcut. Add this to ~/.ssh/config on developer machines:

Host git.example.com
    User git
    Port 2222
    IdentityFile ~/.ssh/id_ed25519

After this, git clone git@git.example.com:alice/my-repo.git works with standard SCP-style notation.

Troubleshooting SSH. The three most common issues: wrong port (forgetting -p 2222), key not recognized (whitespace or line endings corrupting the pasted key), and file ownership problems inside the container. If ssh -vvv shows the server accepting the connection but rejecting authentication, exec into the container and check that /data/git/.ssh/authorized_keys is owned by the git user with mode 600. On fresh deployments with a specific USER_UID/USER_GID, ownership mismatches are the most frequent cause of SSH failures.

1
2
docker exec forgejo ls -la /data/git/.ssh/
# should show: -rw------- 1 git git ... authorized_keys

HTTPS Access and Reverse Proxy

Forgejo should not terminate TLS itself in production. Run it on HTTP internally (port 3000) and place a reverse proxy in front that handles certificates. This keeps certificate management in one place, lets the same proxy front other services, and avoids giving Forgejo access to private keys.

The critical settings when running behind a proxy:

FORGEJO__server__DOMAIN=git.example.com
FORGEJO__server__ROOT_URL=https://git.example.com/
FORGEJO__server__HTTP_PORT=3000

ROOT_URL must exactly match the URL users access — including the https:// scheme and a trailing slash. Getting this wrong breaks OAuth redirects, webhook delivery addresses, clone URLs displayed in the UI, and all email links. It is the single most common misconfiguration in new Forgejo deployments.

Nginx configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
server {
    listen 443 ssl http2;
    server_name git.example.com;

    ssl_certificate     /etc/letsencrypt/live/git.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/git.example.com/privkey.pem;

    client_max_body_size 512m;

    location / {
        proxy_pass         http://127.0.0.1:3000;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_read_timeout 600s;
    }
}

server {
    listen 80;
    server_name git.example.com;
    return 301 https://$host$request_uri;
}

The client_max_body_size 512m directive matters. Nginx defaults to 1 MB, which will silently break any push larger than that. If you use Git LFS with large binary files, set it higher. The proxy_read_timeout 600s prevents Nginx from cutting off large clone operations — the default 60 seconds is too short.

Caddy is a simpler option if you are already using it:

git.example.com {
    reverse_proxy 127.0.0.1:3000
}

Caddy handles TLS certificate provisioning automatically via ACME. Ensure ROOT_URL matches.

Git over HTTPS.

1
git clone https://git.example.com/alice/my-repo.git

For private repositories, Git will prompt for credentials. Avoid re-entry with credential caching:

1
git config --global credential.helper cache --timeout=3600

The store helper (git config --global credential.helper store) writes credentials to ~/.git-credentials in plain text — convenient but unencrypted. On servers running CI pipelines, use Forgejo access tokens scoped to specific repositories (available as of v15) rather than your user password.

HTTPS vs SSH. SSH with a key pair is the right default for daily development — no credential prompts, faster authentication, works regardless of corporate proxy settings. HTTPS is preferable for CI pipelines (no SSH key to manage in secrets, token rotation is straightforward), for read-only clones in automated scripts, and when operating behind firewalls that block non-standard ports. Repository-scoped access tokens introduced in Forgejo v15 are the right credential type for HTTPS in CI — they limit blast radius if a token is leaked.


Organizations, Teams, and Permissions

Forgejo’s permission model closely mirrors GitHub’s organizational model, which makes it intuitive for teams migrating from there.

Organizations create a namespace for repositories and allow multiple owners. A repository owned by myorg/backend-api is distinct from alice/backend-api. Organizations have their own profile pages, member lists, and teams. Any user can create an organization; the organization owner has full administrative control.

Teams are the mechanism for fine-grained access within an organization. You create teams with specific permission levels and add both members and repositories to each team. A team can have access to all organization repositories or to a specific subset — the latter is essential for contractors or external collaborators who should not see every project.

Permission Level Pull (read) Push (write) Create repos Manage members Manage org settings Delete repos
Owner Yes Yes Yes Yes Yes Yes
Admin Yes Yes Yes Yes No Yes
Write Yes Yes No No No No
Read Yes No No No No No
None No access No No No No No

A typical team structure for a small engineering organization:

  • owners — the ops or platform team, full access across everything
  • engineers — Write access to application repositories
  • contractors — Read access to specific repositories only, individual assignment
  • bots — Write access for CI service accounts on specific repositories

Fork vs mirror. A fork creates a writable copy of a repository under a different owner, preserving the upstream relationship so you can open pull requests. A mirror creates a read-only synchronized copy that pulls from an upstream URL on a configurable schedule. Mirrors are useful for keeping a local copy of a critical upstream dependency in case it disappears, or for synchronizing from GitHub to Forgejo for local CI execution.

Protected branches. Configure under Settings → Branches → Add Protected Branch Rule. Key options:

  • Require pull request reviews before merging (configurable count)
  • Require status checks to pass — Forgejo Actions jobs or external CI via commit status API
  • Restrict who can push directly to the branch
  • Prevent force pushes and branch deletion

Merge strategies. Per-repository, you can enable or disable merge commit, squash merge, and rebase merge independently. Disabling merge commits and requiring squash produces a clean linear history on main — a common preference for projects that value a readable git log over preserved branch topology.

Repository visibility. Three levels: Public (visible to anyone, including unauthenticated users if the instance allows it), Private (visible only to explicit collaborators), and Internal (visible to all authenticated members of the organization — useful for internal tooling you want to share org-wide without making fully public on an open instance).


Webhooks

Webhooks are how Forgejo tells the outside world that something happened. They are the integration surface between your Git server and everything else in your infrastructure.

Creating a webhook. In a repository, go to Settings → Webhooks → Add Webhook → Forgejo. Required fields:

  • Payload URL: where Forgejo should POST the event data
  • Content type: use application/json
  • Secret: a shared secret for HMAC-SHA256 request signing — do not skip this
  • Events: individual events (push, pull request, issues, release) or “Send me everything”

Forgejo signs every webhook delivery with a X-Forgejo-Signature header containing sha256=<hmac_of_body>. Your receiving application should verify this signature before acting on the payload. Without verification, anyone who discovers your webhook URL can trigger your pipelines.

Push event payload structure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
  "ref": "refs/heads/main",
  "before": "abc123def456...",
  "after": "789abc012def...",
  "compare": "https://git.example.com/alice/my-repo/compare/abc123...789abc",
  "commits": [
    {
      "id": "789abc012def...",
      "message": "Fix null pointer in payment handler",
      "author": {
        "name": "Alice",
        "email": "alice@example.com",
        "username": "alice"
      },
      "added": ["src/payment/handler.go"],
      "modified": [],
      "removed": []
    }
  ],
  "repository": {
    "id": 42,
    "name": "my-repo",
    "full_name": "alice/my-repo",
    "private": false,
    "clone_url": "https://git.example.com/alice/my-repo.git",
    "ssh_url": "ssh://git@git.example.com:2222/alice/my-repo.git"
  },
  "pusher": {
    "login": "alice",
    "email": "alice@example.com"
  }
}

Delivery history. Under Settings → Webhooks, click on any webhook to see its recent delivery history: the payload sent, the response received, HTTP status code, and timing. Failed deliveries can be re-delivered with one click. This is the most useful debugging tool when setting up a new integration — you can inspect exactly what Forgejo sent and why your endpoint rejected it without needing to stage a real push event.

Common use cases:

  • Trigger Jenkins, Drone, n8n, or a custom deploy script on push or PR merge
  • Post commit notifications or PR alerts to Matrix, Slack, or Discord channels
  • Invalidate a Varnish or CloudFront cache when static site content is merged
  • Update a deployment manifest repository when a container image build completes
  • Stream events to an audit log or SIEM for compliance

Forgejo Actions: CI/CD

Forgejo Actions is a GitHub Actions-compatible CI/CD system built into Forgejo. If you already have GitHub Actions workflows, the translation is mostly a matter of changing where files live and swapping a few action references.

Workflow location. Forgejo reads workflows from .forgejo/workflows/ by default. It also reads .github/workflows/ if .forgejo/workflows/ does not exist — useful during migration. Use .forgejo/workflows/ for new projects to be explicit and avoid creating a false impression that the workflows run on GitHub.

Workflow syntax. The syntax is intentionally GitHub Actions-compatible: on triggers, jobs, steps, uses, env, secrets, needs, matrix strategies, conditional execution with if — all work as expected. Actions from Gitea’s marketplace and code.forgejo.org/actions/ work directly. GitHub-hosted Actions (actions/checkout, actions/setup-go, etc.) also function, though at workflow runtime they are fetched from GitHub, introducing an external dependency. Pin them to specific commit SHAs if supply chain integrity concerns you.

Runner Architecture and Job Flow

Forgejo Actions requires at least one runner to execute jobs. Runners are external processes that long-poll the Forgejo instance for queued jobs, execute them in an isolated environment (Docker container, LXC container, or the host), and report results back.

  Developer workstation
        |
        | git push
        v
  +------------------------+
  |        Forgejo         |
  |                        |
  |  1. parse workflow     |
  |  2. create job record  |
  |  3. queue job          |
  +----------+-------------+
             |
             | long-poll: "any jobs for my labels?"
             |
  +----------+-------------+
  |    forgejo-runner      |
  |    (separate host or   |
  |     Docker container)  |
  |                        |
  |  4. pull runner image  |
  |  5. execute steps      |
  |  6. stream logs back   |
  +----------+-------------+
             |
             | 7. report status + upload artifacts
             v
  +------------------------+
  |        Forgejo         |
  |  update commit status  |
  |  display in PR/branch  |
  +------------------------+

Setting Up a Runner

The runner is a separate binary distributed as a Docker image at data.forgejo.org/forgejo/runner. In Forgejo v15, runner registration gained a web UI: Site Administration → Actions → Runners → Create New Runner generates a registration token without needing CLI access to the server.

Runner docker-compose.yml (run alongside your Forgejo stack or on a separate machine):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
services:
  docker-in-docker:
    image: docker:dind
    container_name: forgejo_dind
    restart: unless-stopped
    privileged: true
    environment:
      - DOCKER_TLS_CERTDIR=/certs
    volumes:
      - dind_certs:/certs/client
      - runner_data:/data
    networks:
      - runner_net

  runner:
    image: data.forgejo.org/forgejo/runner:6
    container_name: forgejo_runner
    restart: unless-stopped
    depends_on:
      - docker-in-docker
    environment:
      - DOCKER_HOST=tcp://docker-in-docker:2376
      - DOCKER_CERT_PATH=/certs/client
      - DOCKER_TLS_VERIFY=1
    volumes:
      - dind_certs:/certs/client:ro
      - runner_data:/data
    command: >
      sh -c "
        while ! forgejo-runner register
          --instance https://git.example.com
          --token $${RUNNER_TOKEN}
          --name runner-01
          --labels ubuntu-latest:docker://node:20-bookworm,docker:docker://docker:latest
          --no-interactive 2>/dev/null;
        do
          echo 'Waiting for Forgejo...';
          sleep 5;
        done;
        forgejo-runner daemon --config /data/config.yml
      "
    networks:
      - runner_net

volumes:
  dind_certs:
  runner_data:

networks:
  runner_net:
    driver: bridge

Set RUNNER_TOKEN in your .env file. After first registration, the runner writes its credentials to /data/.runner in the volume and will not re-register on subsequent starts if that file exists.

Runner registration via CLI (for systemd deployments):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
curl -L https://code.forgejo.org/forgejo/runner/releases/download/v6.0.0/forgejo-runner-linux-amd64 \
  -o /usr/local/bin/forgejo-runner
chmod +x /usr/local/bin/forgejo-runner

forgejo-runner register \
  --instance https://git.example.com \
  --token <token-from-web-ui> \
  --name build-server-01 \
  --labels ubuntu-latest:docker://node:20-bookworm,docker:docker://docker:latest \
  --no-interactive

forgejo-runner generate-config > /etc/forgejo-runner/config.yml
# Edit config.yml as needed, then create and enable a systemd unit

Runner labels determine which jobs a runner will accept. The label format is label:execution-type://image-or-runtime. A runner labeled ubuntu-latest:docker://node:20-bookworm accepts jobs with runs-on: ubuntu-latest and executes them in a node:20-bookworm container. Assign multiple labels to a single runner. For builds requiring specific runtimes, either use a matching image label or install tools in workflow steps via apt-get.

Docker-in-Docker vs host socket. The DinD setup shown above connects the runner’s Docker client to the DinD container over TLS — more isolated but adds operational complexity. The simpler alternative is mounting the host Docker socket (/var/run/docker.sock) into the runner container. The socket mount grants container-level access to the host Docker daemon, which is a meaningful security boundary. For a homelab runner executing trusted code, the socket mount is fine. For a runner that might execute untrusted PR code, use DinD or an LXC-based approach.

A Complete Workflow: Build, Test, and Push to the Registry

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# .forgejo/workflows/build-and-push.yml
name: Build and Push

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

env:
  REGISTRY: git.example.com
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: golang:1.22-bookworm

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Cache Go modules
        uses: actions/cache@v4
        with:
          path: |
            ~/.cache/go-build
            ~/go/pkg/mod
          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
          restore-keys: |
            ${{ runner.os }}-go-

      - name: Run tests
        run: go test ./... -v -race -coverprofile=coverage.out

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage.out

  build-and-push:
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Log in to Forgejo registry
        run: |
          echo "${{ secrets.REGISTRY_PASSWORD }}" | \
            docker login ${{ env.REGISTRY }} \
            --username ${{ secrets.REGISTRY_USERNAME }} \
            --password-stdin

      - name: Build and push Docker image
        run: |
          IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          TAG=${GITHUB_SHA::8}

          docker build -t ${IMAGE}:${TAG} -t ${IMAGE}:latest .
          docker push ${IMAGE}:${TAG}
          docker push ${IMAGE}:latest

      - name: Write step summary
        run: |
          echo "### Image pushed" >> $GITHUB_STEP_SUMMARY
          echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${GITHUB_SHA::8}\`" >> $GITHUB_STEP_SUMMARY

Add REGISTRY_USERNAME and REGISTRY_PASSWORD under Repository Settings → Actions → Secrets. For the container registry, use a Forgejo access token with package write permissions rather than your account password.

Caching. The actions/cache action works with Forgejo’s built-in cache backend. Cache keys follow the same hash-based invalidation pattern as GitHub Actions. Artifacts uploaded with actions/upload-artifact are stored in Forgejo and accessible from the workflow run page for the configured retention period.


Container Registry

Forgejo includes an OCI-compatible container registry under its Packages feature. No separate Harbor or registry deployment needed for most use cases — push and pull directly from your Forgejo instance.

Enabling packages. Packages are enabled by default. If yours are disabled, set FORGEJO__packages__ENABLED=true and restart. Storage lands in /data/gitea/packages/ inside the container — your forgejo_data volume.

Logging in:

1
2
docker login git.example.com
# Enter your Forgejo username and an access token (preferred over your password)

Pushing an image:

1
2
3
4
5
docker build -t git.example.com/alice/myapp:latest .
docker push git.example.com/alice/myapp:latest

docker tag git.example.com/alice/myapp:latest git.example.com/alice/myapp:v1.2.3
docker push git.example.com/alice/myapp:v1.2.3

Pulling:

1
docker pull git.example.com/alice/myapp:latest

The registry URL format is <host>/<owner>/<image>:<tag>. The <owner> can be a user or an organization. Images pushed to an organization namespace appear under the organization’s Packages tab and respect the organization’s visibility settings.

Viewing packages. Every user and organization profile has a Packages tab listing all published packages with available versions, pull commands, and install badges. Packages inherit the visibility of their owner’s namespace — a package under a private organization is inaccessible to unauthenticated users.

Other package types. Forgejo Packages supports npm registries, PyPI, Maven, NuGet, Helm charts, Debian/Alpine package repositories, and a generic file registry. The same authentication model applies across all of them. For smaller organizations, this means you can run your entire internal artifact pipeline — container images, language packages, Helm charts — off a single Forgejo instance with unified access control.

Storage planning. Container images grow faster than source code. Allocate dedicated storage for your forgejo_data volume, or configure S3-compatible external storage for packages via FORGEJO__packages__STORAGE_TYPE=minio with the corresponding MINIO_* environment variables. For a team of 5–10 engineers actively pushing images, a few hundred gigabytes fills up faster than expected.


Migrating from GitHub

Forgejo’s migration wizard is one of its more polished features. It handles GitHub, GitLab, Bitbucket, and Gitea sources and copies not just repository content but also issues, pull requests, labels, milestones, wiki pages, and releases.

Starting a migration. In the Forgejo UI, click the + button and select “Migrate Repository.” Select GitHub as the source. Required fields:

  • Clone URL: your GitHub repository URL (https://github.com/alice/my-repo)
  • Access Token: a GitHub Personal Access Token — required for private repos and for importing issues/PRs; needs repo scope for private, public_repo for public
  • Repository name: what to name it in Forgejo
  • Items to migrate: checkboxes for issues, pull requests, labels, milestones, wiki, releases

What migrates well: full repository content with all branches, tags, and commit history; issues with comments; pull request history with comments and reviews; labels; milestones; releases with attached assets; wiki pages.

What does not migrate: GitHub Actions workflows copy as files but will not run without review — action references, runner labels, and some syntax need adjustment. GitHub Pages configuration has no Forgejo equivalent. GitHub-specific integrations (Dependabot, CodeQL, Advanced Security) have no counterparts. Issue assignees and PR reviewers migrate as name references but will not link to Forgejo accounts unless usernames match.

Updating your local remote after migration:

1
2
3
4
5
git remote set-url origin https://git.example.com/alice/my-repo.git
# or for SSH:
git remote set-url origin ssh://git@git.example.com:2222/alice/my-repo.git

git remote -v

Scripted bulk migration via the Forgejo API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
TOKEN="your_forgejo_api_token"
GITHUB_TOKEN="your_github_pat"
FORGEJO_HOST="https://git.example.com"
FORGEJO_UID=1   # Your Forgejo user ID

curl -X POST "${FORGEJO_HOST}/api/v1/repos/migrate" \
  -H "Authorization: token ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "clone_addr": "https://github.com/alice/my-repo",
    "auth_token": "'"${GITHUB_TOKEN}"'",
    "uid": '"${FORGEJO_UID}"',
    "repo_name": "my-repo",
    "issues": true,
    "pull_requests": true,
    "releases": true,
    "labels": true,
    "milestones": true,
    "wiki": true,
    "private": false
  }'

Mirroring as a hybrid approach. Rather than a hard cutover, set up a pull mirror: Forgejo pulls from GitHub on a schedule (default: 8 hours) while GitHub remains primary. This lets you run CI workloads locally, keep a backup, or gradually shift teams over without a flag-day migration. The downside: a pull mirror is read-only in Forgejo — you cannot push to it until you convert it to a regular repository. Push mirroring (Forgejo pushes to GitHub on every commit) is the inverse approach, useful for maintaining a GitHub presence while working primarily in Forgejo.

Converting GitHub Actions workflows to Forgejo Actions:

  1. Move files from .github/workflows/ to .forgejo/workflows/
  2. Update runs-on labels to match your registered runner labels
  3. Review and pin external action references
  4. Remove or replace GitHub-specific steps (anything calling the GitHub REST API directly, actions/github-script with GitHub-specific queries, Dependabot configuration)

Backup and Restore

A Forgejo deployment has two distinct backup targets: the /data volume and the PostgreSQL database. You must back up both. Backing up only one gives you a partially restorable archive that will fail to work.

What the /data volume contains:

  • Git repositories (bare repos in /data/gitea/repositories/)
  • Configuration (/data/gitea/conf/app.ini)
  • SSH host keys (/data/ssh/)
  • Attached files and avatars
  • LFS objects (/data/gitea/lfs/)
  • Package artifacts (/data/gitea/packages/)

The built-in backup command:

1
2
3
docker exec -u git forgejo forgejo admin backup \
  --config /data/gitea/conf/app.ini \
  --file /data/forgejo-backup-$(date +%Y%m%d).zip

This creates a zip archive containing repositories, config, SSH keys, and attachments. If you are using PostgreSQL — and you should be — the database is not included. The built-in backup only dumps the database when using SQLite. Back up PostgreSQL separately.

PostgreSQL backup:

1
2
docker exec forgejo_db pg_dump -U forgejo forgejo | \
  gzip > /backup/forgejo-db-$(date +%Y%m%d-%H%M%S).sql.gz

Take both the Forgejo backup and the PostgreSQL dump as part of the same script to keep them in sync. Both operations are safe to run against a live instance.

Full backup script (for a daily cron job):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR="/backup/forgejo"
DATE=$(date +%Y%m%d-%H%M%S)
mkdir -p "${BACKUP_DIR}"

echo "[${DATE}] Starting Forgejo backup"

# Dump PostgreSQL
docker exec forgejo_db pg_dump -U forgejo forgejo | \
  gzip > "${BACKUP_DIR}/forgejo-db-${DATE}.sql.gz"

# Dump application data (repos, config, attachments, LFS)
docker exec -u git forgejo forgejo admin backup \
  --config /data/gitea/conf/app.ini \
  --file /data/forgejo-app-${DATE}.zip

# Copy the backup archive out of the container volume
docker cp "forgejo:/data/forgejo-app-${DATE}.zip" \
  "${BACKUP_DIR}/forgejo-app-${DATE}.zip"

# Remove the in-container copy
docker exec forgejo rm "/data/forgejo-app-${DATE}.zip"

# Ship to S3 (requires awscli configured with appropriate credentials)
aws s3 cp "${BACKUP_DIR}/forgejo-db-${DATE}.sql.gz" \
  "s3://my-backups/forgejo/forgejo-db-${DATE}.sql.gz"
aws s3 cp "${BACKUP_DIR}/forgejo-app-${DATE}.zip" \
  "s3://my-backups/forgejo/forgejo-app-${DATE}.zip"

# Prune local backups older than 7 days
find "${BACKUP_DIR}" -name "forgejo-*" -mtime +7 -delete

echo "[${DATE}] Backup complete"

Schedule with cron: 0 2 * * * /usr/local/bin/forgejo-backup.sh >> /var/log/forgejo-backup.log 2>&1

A backup that lives on the same machine as the service it is backing up is a liability, not an asset. Copy to an S3-compatible bucket, a NAS, or a second machine via rsync over SSH. Test restoration periodically — a backup you have never restored is an assumption, not a guarantee.

LFS objects. If you use Git LFS, the objects live in /data/gitea/lfs/ and are included in the forgejo admin backup archive. Verify this by checking the archive contents with unzip -l. LFS objects are content-addressed by SHA256 and can be large — account for them in storage planning.

Restore procedure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 1. Stop the Forgejo container
docker stop forgejo

# 2. Restore PostgreSQL
gunzip -c /backup/forgejo/forgejo-db-TIMESTAMP.sql.gz | \
  docker exec -i forgejo_db psql -U forgejo forgejo

# 3. Extract the application backup into the volume
docker run --rm \
  -v forgejo_data:/data \
  -v /backup/forgejo:/backup:ro \
  alpine sh -c "cd /data && unzip -o /backup/forgejo-app-TIMESTAMP.zip"

# 4. Start Forgejo
docker start forgejo

# 5. Check logs
docker logs -f forgejo

Backing up with Forgejo Actions. You can run the backup workflow from within Forgejo itself on a schedule:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# .forgejo/workflows/backup.yml
name: Daily Backup

on:
  schedule:
    - cron: "0 3 * * *"

jobs:
  backup:
    runs-on: self-hosted
    steps:
      - name: Dump database
        run: |
          docker exec forgejo_db pg_dump -U forgejo forgejo | \
            gzip > /tmp/forgejo-db-$(date +%Y%m%d).sql.gz

      - name: Upload to S3
        run: |
          aws s3 cp /tmp/forgejo-db-$(date +%Y%m%d).sql.gz \
            s3://my-backups/forgejo/
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Note the obvious circularity: if Forgejo is down, this workflow does not run. Complement it with a host-level cron job or an external scheduler for the scenarios that matter most.


Putting It Together

Forgejo is a mature platform for the workloads it targets: small-to-medium engineering teams, homelab infrastructure, and organizations that value data sovereignty and supply chain control over managed service convenience. The single-binary deployment model, combined with Docker Compose and a PostgreSQL backend, keeps the operational footprint small enough that one person can maintain it without it becoming a burden.

The Forgejo/Gitea fork history is worth understanding, not as drama, but as a signal about project direction. Gitea remains a solid choice if you need MIT licensing or have tooling that specifically targets Gitea features. For most new deployments, Forgejo’s non-profit governance, active feature development, and the GPL’s protection against proprietary forking represent the more sustainable long-term choice.

The Forgejo Actions runner ecosystem has matured considerably over the past two years. It is no longer experimental — teams are running production CI on it. The container registry and package repository mean you can eliminate Docker Hub and private PyPI/npm proxies for internal artifacts. These are real operational simplifications, not checkbox features.

The piece most worth watching is federation. The ForgeFed specification is advancing with NLnet funding, and the direction is clear: self-hosted Git instances will eventually be able to interoperate in the same way email servers or Mastodon instances do. When that ships stably, self-hosted Git stops being a purely local concern and becomes part of a distributed, decentralized development ecosystem. The path to get there starts with running your own instance now.

Comments