Files
web-log/container/site/content/posts/gitea-actions/index.md
T
nicholas 2e9bd0e409
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 9s
Build & Push Hugo Site Image / deploy (push) Successful in 16s
add tags, descriptions to posts (#38)
2026-08-05 16:24:33 -05:00

18 KiB

+++ categories = ["software"] tags = ["automation","docker","git","devops","kubernetes"] date = 2025-05-08T12:50:00-05:00 description = "Replacing Jenkins deployment jobs with a self-hosted Gitea Actions runner that builds containers and deploys them to Kubernetes." draft = false slug = "gitea-actions-kubernetes" title = "▶️ Gitea Actions & Kubernetes" author = "nicholas" +++

I currently use Jenkins to automate the build and deployment of my personal website and my blog site. I will experiment with a Gitea Actions workflow to see if I can accomplish the same thing in Gitea.

This process should be familiar to me since the workflow I configure in this post will more or less mirror the behavior of my Jenkins setup: an independent service (e.g., Jenkins agent, Gitea runner) executes steps defined in a repo configuration file (e.g., Jenkinsfile, demo.yml).

I wrote a post for each of my other Jenkins pipelines.

  • [🚀 Hugo Site Deployment]({{< relref "posts/jenkins-deploy-web-log">}})
  • [🤵🏻 Personal Website Deployment]({{< relref "posts/jenkins-deploy-website">}})

Generate Registration token for runner

I will first need to generate a authentication / identification token to register with the Gitea server. This is very simple using the web app GUI. I click the button Create new Runner and it generates the registration token for me. I will use this token later.

{{< image src="images/runnner-registration-token.png" caption="Generate registration token for gitea runner" >}}

I could have generated this using gitea actions generate-runner-token command inside the container, which would give me more control over the scope of the runner (e.g. global, org, repo scope) but I do not care such minutiae right now.

Create Gitea runner container

Next I need to configure and run the runner container. To do this I will:

  • modify docker-compose.yml to include gitea/act_runner
  • add environment variables for REGISTRATION_TOKEN, etc.
  • restart the services.

act_runner

Gitea provides an official image gitea/act_runner. For testing purposes, I will not need anything more than this and the basic ubuntu-based image ubuntu-latest.

This runner will be capable of running GitHub Actions-like workflows in Gitea -- it even uses the same syntax as GitHub Actions. Later, I will configure a workflow using this syntax that defines the "actions" the runner will perform on my repository.

Next I add a runner service to my docker-compose.yml file. Before, there were only 2 containers in the application services: db and gitea. I have added a third: gitea_runner:

networks:
  gitea:
    external: false

services:
  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=mysql
      - GITEA__database__HOST=db:3306
      - GITEA__database__NAME=gitea
      - GITEA__database__USER=gitea
      - GITEA__database__PASSWD=${MYSQL_PASSWORD}
    restart: always
    networks:
      - gitea
    volumes:
      - ./data:/data
      #- ./logs:/var/lib/gitea/log
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "${GITEA_PORT}:3000"
    depends_on:
      - db
    healthcheck:
      test: [ "CMD", "curl", "-f", "http://localhost:3000/api/healthz" ]
      interval: 10s
      timeout: 5s
      retries: 5

  db:
    image: mysql:8
    container_name: gitea_db
    restart: always
    environment:
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
      - MYSQL_USER=gitea
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=gitea
    networks:
      - gitea
    volumes:
      - ./mysql:/var/lib/mysql

  runner:
    image: gitea/act_runner
    container_name: gitea_runner
    restart: always
    depends_on:
      gitea:
        condition: service_healthy
        restart: true
    volumes:
      - ./data/act_runner:/data
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL}
      - GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}
      - GITEA_RUNNER_NAME=${GITEA_RUNNER_NAME}

Configure a demo workflow

Now I need to configure a test workflow using the GitHub Actions syntax I mention above. Gitea provides a demo workflow that I will use to verify that I can successfully run a workflow in a runner container:

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

jobs:
  Explore-Gitea-Actions:
    runs-on: ubuntu-latest
    steps:
      - run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
      - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
      - run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
      - name: Check out repository code
        uses: actions/checkout@v4
      - run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
      - run: echo "🖥️ The workflow is now ready to test your code on the runner."
      - name: List files in the repository
        run: |
          ls ${{ gitea.workspace }}
      - run: echo "🍏 This job's status is ${{ job.status }}."

I place this demo.yml file in my proving-ground repository:

proving-ground/
├── 📂.gitea/
│   └── 📂workflows/
│       └── demo.yml

Before I push this workflow to the repository, I need to finish setting up the runner. Then, once I push the new code, Gitea Actions should trigger the workflow, according to the line on: [push]

Runner test

I bring down the current Gitea application by running docker compose down and start it up again (this time including the new runner) with docker compose up -d. I navigate to the Gitea Runners in the web interface to verify that the runner is registered and running correctly:

{{< image src="images/gitea-runner.jpg" caption="Create runner" >}}

It works.


Now I can commit and push my new workflow test.yml and verify that the runner can perform Gitea Actions. After I push, I navigate to: nicholas/proving-groundActions to view the latest run:

{{< image src="images/test-workflow-running.jpg" caption="Test workflow - running" >}}

{{< image src="images/test-workflow-success.jpg" caption="Test workflow - success" >}}

It works


The runner executed each step defined in the workflow configuration file.

Next I will configure a new workflow to perform a more useful automation.

Build, Deploy

The next step is to build and deploy to a web server. There are many ways to accomplish this depending on how I choose to architect my infrastructure. Since I am only deploying a static website and all work is being performed on the same machine, it would be simple to transfer the site files locally between host and containers. However, I will make things more interesting by artificially introducing arguably unnecessary complexity to the solution. This will move me closer to DevOps pipeline that would be seen in production.

Outline

The pipeline will look something like this:

  • Push changes to site repo, master branch
  • Trigger Action
    • Pull master branch
  • build a Docker image with new content
  • push Docker image to container registry
  • k3s deploys containerized web site

k3s / Kubernetes container

When my action runner connects to the Kubernetes API at https://server:6443 (as per kubeconfig.yml), it verifies the server's TLS certificate. If the hostname in the kubeconfig.yml ("server") does not match any SAN in the cert, the connection will fail with a TLS verification error. I can explicitly add "server" as a valid domain in the TLS cert to prevent this error.

command: server --tls-san "server"

k3s_server:
    image: "rancher/k3s:${K3S_VERSION:-latest}"
    container_name: k3s_server
    command: server --tls-san "server"
    tmpfs:
      - /run
      - /var/run
    ulimits:
      nproc: 65535
      nofile:
        soft: 65535
        hard: 65535
    privileged: true
    restart: always
    environment:
      - K3S_TOKEN=${K3S_TOKEN:?err}
      #- K3S_KUBECONFIG_OUTPUT=/output/kubeconfig.yaml
      - K3S_KUBECONFIG_MODE=666
    networks:
      - cicd
    volumes:
      - k3s-server:/var/lib/rancher/k3s
      - ./k3s:/output
    ports:
      - 6443:6443 # Kubernetes API Server
      - ${INGRESS_CONTROLLER_PORT_HTTP}:80 # Ingress controller port 80
      - ${INGRESS_CONTROLLER_PORT_HTTPS}:443 # Ingress controller port 443

Create static web page

I create a sample static web page index.html that I will serve in my nginx container.

📂site/
├── index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Hello, World!</title>
  <style>
    /*...*/
  </style>
</head>
<body>
  <h1>Behold</h1>
  <p>The Cosmos is all that is or was or ever will be. Our feeblest contemplations of the Cosmos stir us — there is a tingling in the spine, a catch in the voice, a faint sensation, as if a distant memory, of falling from a height. We know we are approaching the greatest of mysteries. </p>
</body>
</html>

---

Create Dockerfile

Next I will create the Dockerfile. This file will define a container that will serve the static web page index.html using nginx. The Dockerfile will remove all of the default files that ship with nginx:alpine and replace them with the contents of my site directory, which contains only index.html.

Dockerfile

FROM nginx:alpine
RUN rm -rf /usr/share/nginx/html/*
COPY ./site/ /usr/share/nginx/html/
EXPOSE 80

Set up credentials for image registry (gitea)

One step in the workflow involves pushing the newly-built application Docker image to my registry. To push, I will need to authenticate with the Gitea server. To do this, I will use a GitHub action called login-action@v3 found in the GitHub Actions marketplace. This action requires a few inputs to work:

  • Registry URL
  • Username
  • Password (access token)

The registry URL and username inputs are straightforward and I can pass these variables along as strings. The access token will need to be passed as a secret. First I generate an access token in Gitea. I will name this gitea-package-registry and give it only the package write permission.

{{< image src="images/gitea-access-token-generate.png" caption="Generate registry token" >}}

Next I will store this as a secret so that I can securely pass it to the runner. I name the secret REGISTRY_TOKEN

{{< image src="images/gitea-access-token-secret.png" caption="Store registry token secret" >}}

{{< image src="images/registry-token-created.png" caption="Gitea registry token" >}}

I can reference this secret in my workflow file ${{ secrets.REGISTRY_TOKEN }}.


Store kubeconfig as Gitea secret

My workflow file needs access to one more secret: my kubeconfig.yml. Storing this as a secret is not as straightforward as the registry access token, since I cannot store a file directly as a secret. I will need encode the file as a base64 string, store that string as a secret named KUBECONFIG_DATA, and decode the string back into the kubeconfig.yml file.

First, my k3s container is configured to automatically produce a kubeconfig.yml and output it to the specified directory. I have included the relevant configuration from my docker-compose.yml file as context.

docker-compose.yml snippet

    environment:
      - K3S_KUBECONFIG_OUTPUT=/output/kubeconfig.yml
    volumes:
      - ./k3s:/output

On my host machine, I can locate the generated kubeconfig.yml file at ./k3s/kubeconfig.yaml. Next, I run the following command to base64-encode it and write the result to kubeconfig.b64.

base64 -w 0 kubeconfig.yaml > kubeconfig.b64

The contents of kubeconfig.b64 is a string that will be stored as a secret in Gitea:

{{< image src="images/kubeconfig-data.png" caption="Base64 encoded secret" >}}

I will reference this secret in my Gitea Action workflow, which is defined in yet another yaml file-- this one stored in the same workflows directory as the demo.yml created in earlier step, during initial stages of setting up the pipeline.

📂proving-ground/
 └─📂.gitea/
    └─📂workflows/
       ├─demo.yml
       └─deploy.yml

Action Workflow file - deploy static web page

The workflow file defines several steps that will compose the deployment process:

  • ⬇️Checkout app repository (proving-ground)
  • 🔐Login to Gitea package registry
  • 🐋Build Docker image from Dockerfile
  • 🚀Push new image to package registry
  • 🛠️Install kubectl
  • 🔑Decode KUBECONFIG_DATA secret and write local kubeconfig.yml file; Use this file as KUBECONFIG env.
  • 🚀Test / Deploy to Kubernetes cluster via API
  • 🧹Prune images

The kubectl CLI utility uses my kubeconfig.yml file to authenticate and connect to the Kubernetes cluster in the k3 container. It communicates with the Kubernetes API over HTTP/HTTPS (https://server:6443).

deploy.yml

name: Deployment

on:
  push:
    branches:
      - master
  workflow_dispatch:

jobs:
  deploy-site:
    name: 'deploy'
    env:
      REGISTRY_USERNAME: nicholas
      IMAGE_REGISTRY: git.uuard.com
      IMAGE_NAME: nicholas/proving-ground
      IMAGE_TAG: latest
    runs-on: ubuntu-latest
    steps:
      - name: ⬇️ Checkout repo
        uses: actions/checkout@v4

      - name: 🔐 Gitea Registry Login
        uses: docker/login-action@v3
        with:
          registry: ${{ env.IMAGE_REGISTRY }}
          username: ${{ env.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_TOKEN }}

      - name: 🐋 Build Docker image
        run: docker build -t $IMAGE_REGISTRY/$IMAGE_NAME:$IMAGE_TAG ./container

      - name: 🚀 Push Docker image
        run: docker push $IMAGE_REGISTRY/$IMAGE_NAME:$IMAGE_TAG

      - name: 🛠️ Setup kubectl
        uses: azure/setup-kubectl@v4
        with:
          version: 'latest'

      - name: 🔑 Configure Kubeconfig
        run: |
          echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > kubeconfig.yml

      - name: 🧪 Test access
        env:
          KUBECONFIG: kubeconfig.yml
        run: kubectl get nodes

      - name: 🚀 Deploy to k3s
        env:
          KUBECONFIG: kubeconfig.yml
        run: |
          kubectl apply -f k8s/deployment.yml
          kubectl apply -f k8s/service.yml
          kubectl apply -f k8s/ingress.yml
          kubectl rollout restart deployment proving-ground
          kubectl rollout status deployment/proving-ground

      - name: 🧹 Cleanup images
        if: always()
        run: docker image prune -f

Diagram

This diagram shows the relationship between the various containers.

graph TD
  subgraph host[Host]
    subgraph docker[Docker]
      subgraph kubernetes[Kubernetes / k3s]
        subgraph pod[Pod]
          container["NGINX"]
        end
      end
      gitea-server[Gitea Server]
      gitea-runner[Gitea Action Runner]
      
      gitea-server <--> gitea-runner
      gitea-runner --Kubernetes API--> kubernetes
    end
  end

Kubernetes deployment manifest

My Kubernetes manifest defines a basic static website deployment using the NGINX container image proving-ground:latest. The manifest configuration includes three resource types:

  • Deployment (deployment.yml)
    • kubectl apply -f k8s/deployment.yml
    • Runs the NGINX container inside a pod
  • Service (service.yml)
    • kubectl apply -f k8s/service.yml
    • Exposes the pod containing the NGINX app, makes it accessible inside the cluster
  • Ingress (ingress.yml)
    • kubectl apply -f k8s/ingress.yml
    • Configures routing rules to expose NGINX app externally

deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: proving-ground
  labels:
    app: proving-ground-static-site
spec:
  replicas: 1
  selector:
    matchLabels:
      app: proving-ground-static-site
  template:
    metadata:
      labels:
        app: proving-ground-static-site
    spec:
      containers:
        - name: nginx
          image: git.uuard.com/nicholas/proving-ground:latest
          ports:
            - containerPort: 80

ingress.yml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: proving-ground-static-site-ingress
spec:
  rules:
    - host: server
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: proving-ground-static-site-service
                port:
                  number: 80

service.yml

apiVersion: v1
kind: Service
metadata:
  name: proving-ground-static-site-service
spec:
  selector:
    app: proving-ground-static-site
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  type: LoadBalancer

Site repo directory structure

This is the complete repository directory structure.

📁proving-ground
 ├─📁.gitea
 │  └─📁workflows
 │     ├─demo.yml
 │     └─deploy.yml
 ├─📁container
 │  ├─Dockerfile
 │  └─📁site
 │     └─index.html
 └─📁k8s
    ├─deployment.yml
    ├─ingress.yml
    └─service.yml

Test Kubernetes API / kubectl

Now I can test the Kubernetes connection by running kubectl get nodes. If this step succeeds, I have verified the connection between the runner container and Kubernetes. I can trigger this build by either pushing to master or manually triggering the build via Gitea UI.

{{< image src="images/k3s-test-success.png" caption="Deployment test success" >}}

It works


Deploy

Now I deploy the app. I simply push changes to the repository, and voilà, my app is live.

{{< image src="images/k3s-deploy-success.png" caption="Deployment success" >}}

{{< image src="images/view-site-deployment.png" caption="View deployment in browser" >}}

🎉 It works! Done.