Published on

Configuring Private Runner Images for Gitea Runner

Authors
  • avatar
    Name
    Jay
    Twitter

Reason Why I Picked Gitea

I needed to run a self-hosted Git system instead of using a SaaS platform like GitHub or Bitbucket. My main requirements were that the solution be open-source and have a great web UI.

At first, GitHub Enterprise and GitLab Community Edition came to mind. However, GitHub Enterprise isn't open-source, which led me to believe that GitLab CE was the only option that met my criteria.

After a little more research, I found Gitea. A quick check revealed that it not only has a great web UI but also provides a CI/CD pipeline compatible with GitHub Actions. It also offers support for Git LFS and OAuth2 authentication.

I also prefer open-source projects that are maintained by a company that offers an enterprise version, as I believe this ensures better long-term reliability. Gitea fits this preference, with an enterprise option provided by the company CommitGo.

Finally, Gitea has a simpler architecture with fewer components (Gitea, PostgreSQL, Valkey, and an Act Runner) compared to GitLab. This was important to me because I sometimes need to modify Dockerfiles and build images from source to meet specific security requirements. For that reason, a system with fewer components was an advantage.

Act

Gitea provides a CI/CD pipeline compatible with GitHub Actions by using the open-source project Act. Act allows you to run GitHub Action workflows on your local machine by parsing the workflow specification and running it in a container environment via the Docker API. While GitHub Actions operate in a virtualized environment, Act uses a container environment, and the container it uses is called a Runner. You can configure which container image to use for this Runner.

Act provides a CLI that allows you to run workflows on your local machine. If Docker is running on your system, this single command will run the workflow specified in a file like ~/.github/workflows/something.yml. It's awesome!

act

Gitea forked "Act" and modified it to be used as a library for its own Gitea Act Runner. The Gitea Act Runner integrates with Gitea and runs workflows using this forked Act library. Gitea Act reads workflow files from the .gitea/workflows path instead of .github/workflows. However, the workflow specification is still compatible with GitHub Actions. That's why you can run your Gitea workflows with the Act CLI using the -W option, like below.

act -W ./.gitea/workflows/

Private Image For Runner

The Gitea Act Runner repository has examples for running it on Kubernetes. I started with the dind-docker.yaml example, and it was working great! In this setup, a container with the Docker-in-Docker (DinD) image runs as a sidecar. The Act Runner container communicates with this DinD container and creates new containers within it through the Gitea Act library.

This process was quick and smooth until I tried to use a container image from a private repository for my Runner. To run your own container image as a runner, you add it to the labels in the configuration.

data:
  config.yaml: |
    log:
      level: debug

    runner:
      file: .runner
      capacity: 1
      timeout: 3h
      shutdown_timeout: 0s
      insecure: false
      fetch_timeout: 5s
      fetch_interval: 2s
      labels:
        - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
        - "ubuntu-22.04:docker://my-private-repository.com/ubuntu:22.04"

    cache:
      enabled: false

    container:
      privileged: true
      docker_host: tcp://localhost:2376
      force_pull: false
      force_rebuild: false

I created a Kubernetes secret in the docker-registry format (kubectl create secret docker-registry regcred) to authenticate with my private container registry. My first thought was that the DinD container needed these credentials, so I mounted the secret volume onto that container. This assumption cost me a significant amount of time and led me down a rabbit hole into the source code. 🥹

- name: docker-config
    secret:
        secretName: regcred
- name: docker-config
    mountPath: /root/.docker/config.json
    subPath: .dockerconfigjson
    readOnly: true

How To Load Docker Config File

To cut to the chase, the Gitea Act Runner container needs the Docker authentication credentials, not the DinD container. The Act library itself is responsible for pulling images and running containers through the Docker API. Therefore, the Act Runner must be the one to make the authenticated request to the Docker daemon.

You can understand this better by looking at the source code of Gitea Act, specifically the LoadDockerAuthConfig method. It loads credentials from a .docker/config.json file and attaches them to the request for authentication.

func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (types.ImagePullOptions, error) {
	imagePullOptions := types.ImagePullOptions{
		Platform: input.Platform,
	}
	logger := common.Logger(ctx)

	if input.Username != "" && input.Password != "" {
		logger.Debugf("using authentication for docker pull")

		authConfig := registry.AuthConfig{
			Username: input.Username,
			Password: input.Password,
		}

		encodedJSON, err := json.Marshal(authConfig)
		if err != nil {
			return imagePullOptions, err
		}

		imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
	} else {
		authConfig, err := LoadDockerAuthConfig(ctx, input.Image)
		if err != nil {
			return imagePullOptions, err
		}
		if authConfig.Username == "" && authConfig.Password == "" {
			return imagePullOptions, nil
		}
		logger.Info("using DockerAuthConfig authentication for docker pull")

		encodedJSON, err := json.Marshal(authConfig)
		if err != nil {
			return imagePullOptions, err
		}

		imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
	}

	return imagePullOptions, nil
}

Final Version

Here is the final version of my modified Gitea example.

kind: ConfigMap
apiVersion: v1
metadata:
  name: act-runner-config
data:
  config.yaml: |
    log:
      level: debug

    runner:
      file: .runner
      capacity: 1
      timeout: 3h
      shutdown_timeout: 0s
      insecure: false
      fetch_timeout: 5s
      fetch_interval: 2s
      labels:
        - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
        - "ubuntu-22.04:docker://my-private-repository.com/ubuntu:22.04"

    cache:
      enabled: false

    container:
      privileged: true
      docker_host: tcp://localhost:2376
      force_pull: false
      force_rebuild: false
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: act-runner-vol
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 100Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: act-runner
  name: act-runner
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: act-runner
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: act-runner
    spec:
      restartPolicy: Always
      volumes:
        - name: runner-config
          configMap:
            name: act-runner-config
        - name: docker-config
          secret:
            secretName: regcred
        - name: docker-certs
          emptyDir: {}
        - name: runner-data
          persistentVolumeClaim:
            claimName: act-runner-vol
      containers:
        - name: runner
          image: gitea/act_runner:nightly
          command:
            [
              'sh',
              '-c',
              "while ! nc -z localhost 2376 </dev/null; do echo 'waiting for docker daemon...'; sleep 5; done; /sbin/tini -- run.sh",
            ]
          env:
            - name: DOCKER_HOST
              value: tcp://localhost:2376
            - name: DOCKER_CERT_PATH
              value: /certs/client
            - name: DOCKER_TLS_VERIFY
              value: '1'
            - name: GITEA_INSTANCE_URL
              value: http://gitea-http.gitea.svc.cluster.local:3000
            - name: GITEA_RUNNER_REGISTRATION_TOKEN
              value: { registration_token }
            - name: CONFIG_FILE
              value: /etc/runner/config.yaml
          volumeMounts:
            - name: runner-config
              mountPath: /etc/runner/config.yaml
              subPath: config.yaml
              readOnly: true
            - name: docker-certs
              mountPath: /certs
            - name: runner-data
              mountPath: /data
            - name: docker-config
              mountPath: /root/.docker/config.json
              subPath: .dockerconfigjson
              readOnly: true
        - name: daemon
          image: docker:23.0.6-dind
          env:
            - name: DOCKER_TLS_CERTDIR
              value: /certs
          securityContext:
            privileged: true
          volumeMounts:
            - name: docker-certs
              mountPath: /certs

When your workflow uses the ubuntu-22.04 label, as shown below, the runner will pull the corresponding container image from the private registry you configured.

name: Gitea Actions Demo
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
on: [push]

jobs:
  Explore-Gitea-Actions:
    runs-on: ubuntu-22.04
    steps:

Try Setting Up With Gemini CLI

I've realized I should be leveraging AI services more to improve my daily work efficiency. I've also learned that I need to experiment to find better ways to prompt LLMs. With that in mind, I tried using Gemini to set up a private container image in Gitea Runner.

My first prompt to the Gemini CLI was to modify the example file to use a private container image.

This YAML file runs a Gitea Runner on Kubernetes. Gitea Runner is currently set up to run a runner using a public container image by default. Please change it to use a private image instead. The image should be ubuntu:24.04 from the repository example.com. @dind-docker.yaml

However, it tried to solve the problem by adding environment variables that don't exist in the Gitea Runner.

- name: RUNNER_CONTAINER_IMAGE
	value: example.com/ubuntu:24.04
- name: RUNNER_CONTAINER_USERNAME
	valueFrom:
		secretKeyRef:
			name: runner-secret
			key: username
- name: RUNNER_CONTAINER_PASSWORD
	valueFrom:
		secretKeyRef:
			name: runner-secret
			key: password

Next, I asked it to create a new Kubernetes manifest from scratch without providing an example, but it generated the same incorrect response.

I noticed some strange behavior. I confirmed that both the Gemini CLI and the gemini.google.com web console were set to use Gemini 2.5 Pro. However, when I submitted the same prompt to the web console, it produced a different result.🤔 It correctly added a label for the private image in the runner's configuration and mounted the Docker config file for authentication.

# -------------------
# 1. Secret for Private Repository Authentication
# -------------------
# Remember to create this with your actual credentials using kubectl:
# kubectl create secret docker-registry regcred \
#   --docker-server=example.com \
#   --docker-username=<your-username> \
#   --docker-password=<your-password>
#
# This is a placeholder manifest for reference.
apiVersion: v1
kind: Secret
metadata:
  name: regcred
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: eyJhdXRocyI6eyJleGFtcGxlLmNvbSI6eyJhdXRoIjoiWVdSdGFXNDZNVEE1T1RrNU9USXpPRGsyIn19fQ== # This is a placeholder for your encoded credentials

---
# -------------------
# 2. ConfigMap for Gitea Runner Configuration
# -------------------
apiVersion: v1
kind: ConfigMap
metadata:
  name: gitea-runner-config
data:
  config.yaml: |
    # Gitea server connection details
    gitea:
      instance: "https://your-gitea-instance.com"
      token: "YOUR_RUNNER_TOKEN" # Replace with your Gitea runner token
      insecure: false

    runner:
      # The name of the runner
      name: "k8s-runner"
      # This section maps a friendly name to a container image for jobs
      labels:
        # This is the new label for your private image
        - "ubuntu-24.04:docker://example.com/ubuntu:24.04"
        # You can keep other labels for public images
        - "ubuntu-latest:docker://gitea/runner-images/ubuntu-latest"

    # Settings for the container runner
    container:
      # Use docker-in-docker sidecar
      docker_host: "tcp://localhost:2375"

---
# -------------------
# 3. Deployment for the Gitea Runner
# -------------------
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gitea-runner-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: gitea-runner
  template:
    metadata:
      labels:
        app: gitea-runner
    spec:
      containers:
        # Main Gitea Runner container
        - name: runner
          image: gitea/act_runner:latest
          env:
            - name: CONFIG_FILE
              value: /config/config.yaml
          volumeMounts:
            # Mount the ConfigMap containing config.yaml
            - name: config-volume
              mountPath: /config
            # Mount the Docker credentials into the runner's home directory
            # This allows the runner to authenticate when pulling job images
            - name: docker-config
              mountPath: /root/.docker
              readOnly: true
        # Docker-in-Docker sidecar container
        - name: dind
          image: docker:dind
          securityContext:
            privileged: true
          volumeMounts:
            - name: docker-graph-storage
              mountPath: /var/lib/docker
      volumes:
        # Volume for the ConfigMap
        - name: config-volume
          configMap:
            name: gitea-runner-config
        # Volume for the Docker credentials Secret
        - name: docker-config
          secret:
            secretName: regcred
        # EmptyDir volume for Docker-in-Docker storage
        - name: docker-graph-storage
          emptyDir: {}

Conclusion

In the end, I chose Gitea for my self-hosted Git system. Although I initially struggled to set up the Docker credentials correctly, the process led me to discover the fascinating open-source project, Act.

Understanding how Act works helped me realize that the Gitea Act Runner loads the Docker configuration file and passes it to the Docker daemon via the API. My journey with Gitea has just begun!