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.ymlto includegitea/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-ground → Actions 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,
masterbranch - Trigger Action
- Pull
masterbranch
- Pull
- build a Docker image with new content
- push Docker image to container registry
k3sdeploys 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_DATAsecret and write localkubeconfig.ymlfile; Use this file asKUBECONFIGenv. - 🚀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.