DEV Community

Tristan Kilhwan Chai
Tristan Kilhwan Chai

Posted on

[Homelab AI Project] Ep.5 - Building a Lightweight Self-Hosted CI/CD Pipeline with Gitea and Act Runner

1. The Challenge: Keeping Git and CI/CD under 150MB of RAM

In Episode 4, we decoupled our PostgreSQL database, Redis task queue, and RSS crawler module into isolated Docker containers with strict memory ceilings. However, a manual deployment loop—SSHing to the server, pulling source code, and running rebuild commands—violates basic principles of engineering efficiency.

We needed a local, self-hosted Git server and an automated CI/CD pipeline. The industry standard, GitLab, comes with a steep recommendation:

"GitLab requires at least 4GB of RAM (8GB recommended) to function smoothly."

Allocating 25% of our entire 16GB homelab memory to a tool that simply hosts repos and triggers pipelines is a critical FinOps failure. That 4GB of RAM is better utilized feeding our database page cache or Python RAG pipeline.

Our target was to achieve a git-triggered deployment pipeline with:

  • Under 150MB active RAM consumption.
  • Support for GitHub Actions YAML syntax (to preserve portable configuration).
  • Direct container control to rolling-update the crawler service.

The solution: Gitea paired with Gitea Actions Runner (Act Runner). Written in Go, Gitea runs natively in lightweight environments, and its Act Runner executes standard GitHub Actions workflow files seamlessly.


2. Topology: Internal Network Bridging and Docker Daemon Delegation

To deploy this without exposing raw ports to the internet or introducing unnecessary routing overheads, we set up two integration boundaries:

① Host Docker Daemon Binding (/var/run/docker.sock)

The Act Runner container maps the host's /var/run/docker.sock file. When a build job triggers a container update, it passes commands directly to the host's Docker daemon, allowing the runner container to remain lightweight.

② Loopback and Internal DNS Direct Routing

We bind both Gitea and Act Runner to the internal docker bridge network (ainews-internal). The runner polls Gitea using the internal DNS address (http://ainews-git:3000), ensuring build traffic never loops through external routers or Cloudflare edge servers.

graph TD
    subgraph Host_OS ["Rocky Linux 10.1 (Host)"]
        subgraph Int_Net ["ainews-internal Network (Isolated)"]
            Gitea["Gitea Server (ainews-git) <br> [Port: 3000, 2222]"]
            Runner["Act Runner (ainews-runner)"]
        end

        Docker_Sock["/var/run/docker.sock <br> (Host Docker Socket)"]
        Target_Cont["ainews-collector <br> (Collector Container)"]
    end

    Local_Dev["Local Developer PC <br> (git push)"] -- "Cloudflare Tunnel <br> (https://git.your-domain.com)" --> Gitea
    Runner -- "Polling <br> (http://ainews-git:3000)" --> Gitea
    Runner -- "docker compose up -d --build <br> (Control Daemon)" --> Docker_Sock
    Docker_Sock --> Target_Cont
Enter fullscreen mode Exit fullscreen mode

3. Setup: infra/docker-compose.gitea.yml & Workflow

📄 infra/docker-compose.gitea.yml

version: '3.8'

networks:
  ainews-internal:
    external: true

services:
  ainews-git:
    image: gitea/gitea:1.22
    container_name: ainews-git
    restart: always
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=postgres
      - GITEA__database__HOST=ainews-db:5432
      - GITEA__database__NAME=giteadb
      - GITEA__database__USER=tristan
      - GITEA__database__PASSWD=super-secret-password-change-me
    volumes:
      - ./volumes/gitea:/data:z
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3000:3000"
      - "2222:22"
    networks:
      - ainews-internal
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M

  ainews-runner:
    image: gitea/act_runner:latest
    container_name: ainews-runner
    restart: always
    depends_on:
      - ainews-git
    environment:
      - CONFIG_FILE=/config.yaml
      - GITEA_INSTANCE_URL=http://ainews-git:3000
      - GITEA_RUNNER_REGISTRATION_TOKEN=your_runner_registration_token_here
      # Map custom label for our Rocky Linux environment instead of the default ubuntu
      - GITEA_RUNNER_LABELS=rocky-linux:docker://node:22-slim
    volumes:
      - ./volumes/act_runner:/data:z
      - /var/run/docker.sock:/var/run/docker.sock
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    networks:
      - ainews-internal
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
Enter fullscreen mode Exit fullscreen mode

📄 .gitea/workflows/deploy.yml

name: AI News Auto Deploy

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: rocky-linux # Custom runner label configured in config.yaml / runner labels
    steps:
      - name: Checkout Source Code
        uses: actions/checkout@v4

      - name: Rebuild & Restart Target Service
        run: |
          echo "Start deploying AI News application..."
          docker compose -f infra/docker-compose.yml up -d --build ainews-collector
          echo "Deployment finished successfully!"
Enter fullscreen mode Exit fullscreen mode

4. Real-World Troubleshooting

🚨 Issue 1: Runner Loopback Failure via External Domain Addresses

  • Symptom: Pointing GITEA_INSTANCE_URL to https://git.your-domain.com threw socket timeout and connection refused errors.
  • Cause: Requests from the runner routed out through the WAN and back into Cloudflare Tunnel. Lacking NAT loopback translation rules inside the local router, internal packets were dropped.
  • Resolution: Keep traffic inside the bridge network by utilizing Gitea's internal container alias (http://ainews-git:3000).

🚨 Issue 2: Docker Socket Bind Permission Denied (GID Mismatch)

  • Symptom: Runner container threw Permission denied: failed to connect to /var/run/docker.sock during deployment steps.
  • Cause: The host's socket requires root or docker group membership. The runner's inner container user (UID 1000) was not mapped to the host's docker GID (often 999 or 992).
  • Resolution: Configured GID group delegation by granting the runner process elevated socket access inside the Docker config, or configuring group mapping settings to line up internal UIDs with the host's Docker socket groups.

🚨 Issue 3: Missing 'Actions' Repository Settings UI

  • Symptom: Repository landing page did not display the Actions configuration tab.
  • Cause: Out of resource safety concerns, Gitea disables actions processing on initial startup.
  • Resolution: Appended configuration flags to Gitea's custom configurations file:
  # edit volumes/gitea/gitea/conf/app.ini
  [actions]
  ENABLED = true
Enter fullscreen mode Exit fullscreen mode

A quick container restart loaded the configurations, activating the menu.

🚨 Issue 4: Disappearing Host Port Bindings (3000) leading to 502 Bad Gateway

  • Symptom: Gitea container failed to resolve external connections, routing to a 502 Bad Gateway error. Running docker ps revealed host port bindings (0.0.0.0:3000->3000/tcp) were missing, showing only raw container ports (22/tcp, 3000/tcp).
  • Cause: The Gitea service was connected solely to the infra_ainews-internal network. By default, the Docker daemon drops and blocks all host port forwarding rules for containers isolated purely inside internal: true bridge networks for strict access segregation.
  • Resolution: Map both ainews-internal and the public bridge network ainews-external to Gitea in docker-compose.gitea.yml:
      networks:
        - ainews-internal
        - ainews-external  # <- Allow host port forwarding by joining public network
Enter fullscreen mode Exit fullscreen mode

Restarting the stack created the correct iptables forwarding rules, resolving the bad gateway immediately.

🚨 Issue 5: 'token is empty' Registration Failure for Gitea Act Runner

  • Symptom: The runner container looped on startup, throwing Error: token is empty even though we mapped the token in the configuration.
  • Cause: Depending on the specific image tag, Gitea Act Runner expects either GITEA_RUNNER_TOKEN or GITEA_RUNNER_REGISTRATION_TOKEN. Environment parser mismatch passed an empty string inside the final configuration layout.
  • Resolution: Map both environment variables to the registration token inside docker-compose.gitea.yml:
        - GITEA_RUNNER_TOKEN=your_token_here
        - GITEA_RUNNER_REGISTRATION_TOKEN=your_token_here
Enter fullscreen mode Exit fullscreen mode

Enforcing both variables bypasses image version compatibility issues, registering the runner instantly.


5. Results & Metrics

Here are the resource footprint improvements compared to GitLab:

Idle Hardware Resource Usage Comparison

Metric GitLab CE Gitea (PostgreSQL) Gitea Act Runner
Idle Memory Footprint 4.2 GiB 118 MiB 28 MiB
Idle CPU Utilization ~2.5% ~0.05% ~0.01%
Active Build Memory 5.0+ GiB ~180 MiB ~210 MiB (Host Socket)
  • 96.5% Memory Savings: Instead of dedicating 4.2GB to GitLab, Gitea and its runner require only 146MB, freeing up over 4GB of raw hardware capacity for database index caching and memory-intensive Python vector searches.
  • 5-Second Deployment Lead Time: Pushing code updates triggers the local agent immediately, completing target rebuilds in 4.8 seconds with zero human interaction.

6. Next Up

With container structures and CI/CD pipelines isolated, Episode 6 explores client interfaces. We will configure our frontend using Astro and React's Island Architecture to maintain high rendering speeds and minimal Javascript runtimes.

Top comments (0)