> ## Content Index
> Fetch the complete content index at: https://blog.claeyscloud.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Migrating from Gitea to Forgejo
- URL: https://blog.claeyscloud.com/migrating-from-gitea-to-forgejo/
- Published: 2026-11-11T17:06:03.000Z
- Updated: 2026-11-11T18:12:03.000Z
- Description: After years of using Gitea, concerns about its direction finally pushed me to migrate to Forgejo. Here's how the migration went, including repositories, container images, and setting up an isolated Forgejo Actions runner with Docker-in-Docker.
- Author: David Claeys
- Tags: Forgejo, Git, CI/CD

I migrated because I was becoming increasingly worried about the future of Gitea.

I already wasn't happy with how the company behind Gitea handled the takeover of the community project. However, I had been using Gitea for a long time, it worked well, and migrating everything wasn't something I wanted to do without a good reason.

That changed when I came across a post from one of Gitea's lead developers describing Gitea as essentially a hobby project. That raised all kinds of red flags for me and finally pushed me to jump ship to what I consider the spiritual successor to Gitea: Forgejo.

Forgejo itself was created in response to the changes surrounding Gitea in 2022\. It originally started as a soft fork of Gitea, with the goal of keeping the project community-driven. In 2024, Forgejo became a hard fork and has since followed its own development path.  
For me, that's an important distinction. Forgejo represents many of the things I originally appreciated about Gitea: a project built by the community, for the community, where the community remains in control of its direction.

## Migrating the repositories

Unfortunately, the version of Gitea I was running was already too new to perform a straightforward database migration to Forgejo.

Instead, I migrated my repositories individually using Forgejo's built-in migration functionality. This took a little more work, but also gave me the opportunity to start with a clean Forgejo installation rather than carrying the entire old Gitea installation over.

There was, however, one important thing that didn't come along for the ride: container images.

## Migrating the container registry

I host several container images in my Git registry, and some of them are publicly available and used by other people. Simply abandoning the images in the old Gitea registry therefore wasn't really an option.

Forgejo's repository migration didn't migrate those images, so I needed another way to transfer them.

I ended up writing a small script using skopeo and jq that discovers all tags for an image in the old registry and copies them directly to the new Forgejo registry.

```sh
gitea_instance='gitea.example.com'
gitea_token='gitea_token'
forgejo_instance='forgejo.example.com'
forgejo_token='forgejo_token'
owner=$1
package_name=$2

for t in $(skopeo list-tags docker://$gitea_instance/$owner/$package_name --creds $owner:$gitea_token | jq -r '.Tags[]'); do
  skopeo copy --all --src-creds $owner:$gitea_token --dest-creds $owner:$forgejo_token --dest-tls-verify=false docker://$gitea_instance/$owner/$package_name:$t docker://$forgejo_instance/$owner/$package_name:$t
done

```

The script requires skopeo and jq and can be run as:  
`./package.sh owner package_name`.

It enumerates all tags for the specified package and uses skopeo copy --all to transfer each image directly between the two registries, including all available architectures.

## Setting up a Forgejo Actions runner

The other part of the migration that required considerably more work than I expected was Forgejo Actions.

Forgejo Runner puts more thought into isolating Actions jobs from the underlying Docker host.  
While this is much better from a security perspective, it does make things a bit more complicated to set up.

The first step we need to take is to go to Site Administration/Actions/Runners.  
When we press the **Create new runner** button we'll get to see the runner *UUID* and *token*. Note these down, since we're going to need them.

The Docker Compose stack for the runner needs two directories: ./data and ./cert.

Inside the ./data directory, we're going to create a `runner-config.yml` file with the following contents:

```yaml
log:
  level: info
  job_level: info
runner:
  # Where to store the registration result.
  file: .runner
  # Execute how many tasks concurrently at the same time.
  capacity: 4
  # Extra environment variables to run jobs.
  envs:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_CERT_PATH: /certs/client
    DOCKER_TLS_VERIFY: "1"
  env_file: .env
  timeout: 3h
  shutdown_timeout: 3h
  insecure: false
  fetch_timeout: 30s
  fetch_interval: 2s
  report_interval: 1s
  labels: []

cache:
  enabled: true
  port: 0
  dir: ""
  external_server: ""
  secret: ""
  secret_url: ""
  host: ""
  proxy_port: 0
  actions_cache_url_override: ""
container:
  network: "host"
  enable_ipv6: false
  privileged: false
  options: --volume /certs/client:/certs/client:ro
  workdir_parent:
  valid_volumes: 
    - "/certs/client"
  force_pull: false
  force_rebuild: false
host:
  workdir_parent:
server:
  connections:
    forgejo:
      url: https://forgejo.example.com
      uuid: runner_uuid
      token: runner_token
      labels:
         -  ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-24.04

```

Your `compose.yaml` should be something like this:

```yaml
services:
  docker:
    image: data.forgejo.org/oci/docker:dind
    privileged: true
    environment:
      DOCKER_TLS_CERTDIR: /certs
    volumes:
      - ./cert:/certs
    restart: unless-stopped
    networks:
      - forgejo-runner
  runner:
    image: data.forgejo.org/forgejo/runner:13
    depends_on:
      docker:
        condition: service_started
    environment:
      DOCKER_HOST: tcp://docker:2376
      DOCKER_CERT_PATH: /certs/client
      DOCKER_TLS_VERIFY: "1"
    user: "1001:1001"
    volumes:
      - ./data:/data
      - ./cert:/certs
    restart: unless-stopped
    command: forgejo-runner daemon --config runner-config.yml
    networks:
      - forgejo-runner
networks:
  forgejo-runner:
    name: forgejo-runner

```

I know this is quite a lot to unpack, so I'll do my best to explain what's going on.  
First of all, this setup doesn't give the Forgejo Runner direct access to the Docker daemon of the host machine. Instead, we're running a separate Docker daemon using Docker-in-Docker (DinD), and the runner connects to that daemon.

This is an important distinction. Giving a CI runner access to /var/run/docker.sock effectively gives jobs a very high level of control over the Docker host. By using a separate Docker daemon, we're adding a layer of isolation between our Actions jobs and the machine hosting the runner.

The `runner-config.yml` file contains the configuration for the Forgejo Runner itself.

### Runner labels

Let's first have a look at the labels section:

```yaml
labels:
  - ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-24.04

```

A label tells Forgejo which runner can execute a job and which environment should be used for it.  
In this example, jobs targeting ubuntu-latest will run inside the ghcr.io/catthehacker/ubuntu:act-24.04 container image.  
Forgejo recommends the images from the [catthehacker/docker\_images repository](https://github.com/catthehacker/docker%5Fimages?ref=blog.claeyscloud.com), although you can use another suitable container image if you prefer.

### Connecting jobs to Docker

Our Forgejo Runner already runs inside a Docker container and uses the separate docker service to start the containers in which our Actions jobs run.

However, there's another problem. What if one of those Actions jobs itself needs to use Docker?

The job container doesn't automatically know how to reach our Docker-in-Docker daemon. That's why we add the following environment variables to the runner configuration:

```yaml
runner:
  envs:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_CERT_PATH: /certs/client
    DOCKER_TLS_VERIFY: "1"

```

These are intentionally similar to the values used by the runner container in our `compose.yaml`, but they serve a different purpose.

The variables in `compose.yaml` configure the Forgejo Runner container itself:

```yaml
services:
....
   runner:
     ...
      environment:
        DOCKER_HOST: tcp://docker:2376
        DOCKER_CERT_PATH: /certs/client
        DOCKER_TLS_VERIFY: "1"

```

The variables under the runner service are passed to the runner container, allowing it to connect to our Docker-in-Docker daemon. Instead of using the physical host's Docker daemon to create job containers, the runner uses this separate daemon, keeping those operations isolated from the host's Docker environment.

Without the variables in our `runner-config.yml`, the runner would still be able to communicate with our Docker-in-Docker daemon, but commands such as `docker build` inside an Actions job wouldn't know where to find it. The Docker client would try to connect to a local Docker daemon instead and fail because there isn't one running inside the job container.

The Docker-in-Docker service is configured to use TLS. This means that a client certificate is required to establish a connection.  
The Docker-in-Docker service generates these certificates in */certs*, which we've mounted into both the docker and runner containers.

But remember that our Actions jobs run inside their own containers. They don't automatically inherit the runner container's filesystem.  
That's where the following configuration becomes important:

```yaml
container:
  network: "host"
  options: --volume /certs/client:/certs/client:ro
  valid_volumes:
    - "/certs/client"

```

The `network: "host"` setting might look a bit odd at first. Don't worry, it doesn't mean that the Actions job is being attached directly to the physical host's network.

Remember that the Forgejo Runner itself is running inside a container. From the perspective of the Actions job container, the runner is its host. The runner is attached to our forgejo-runner Docker Compose network, which is also where our Docker-in-Docker service lives.

Using `network: "host"` here therefore allows the job container to use the runner's network environment, making it possible to reach our Docker-in-Docker service at docker:2376.

Being able to reach the Docker daemon isn't enough, though. Since the connection uses TLS, the job also needs access to the client certificates we mentioned earlier.  
The **options** setting tells the runner to mount */certs/client* inside the Actions job container.

```yaml
options: --volume /certs/client:/certs/client:ro

```

This makes the client certificates available inside the job container at /certs/client, which is the location we specified earlier using **DOCKER\_CERT\_PATH**. The :ro suffix mounts the certificates as read-only, since the job only needs to use them to establish the TLS connection.

```yaml
valid_volumes:
  - "/certs/client"

```

Finally, **valid\_volumes** tells the runner that it's allowed to mount */certs/client* into the job container.

With this setup, the Forgejo Runner and Actions jobs can both use Docker without getting direct access to the Docker daemon of the physical host. Instead, the separate Docker-in-Docker daemon handles the containers used by our Actions jobs, with TLS securing the connections to it.