DEV Community

Tristan Kilhwan Chai
Tristan Kilhwan Chai

Posted on Edited 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.

🚨 Issue 6: Container Loop Crash due to Missing package.json under DooD Bind Mounting

After integrating automated deployment, triggering a git push caused the ainews-collector container to crash in a Restarting (254) loop, throwing npm error enoent Could not read package.json.

  • Cause: Our Gitea Act Runner operates in a DooD (Docker-out-of-Docker) architecture by sharing /var/run/docker.sock. When the runner triggers docker compose up --build, the host's Docker daemon resolves the bind-mount volumes: - ./collector:/usr/src/app relative to the host OS path, not the runner's internal directory. Since the host directory was empty, the container started with an empty working directory.
  • Resolution: We removed the host volume bind-mount entirely to eliminate physical file system coupling. Instead, we introduced a local Dockerfile under packages/collector that uses COPY instructions to package source files directly into the image. Now, the Gitea Actions builder packs the latest commit into the image itself, allowing standalone container rollouts independent of host folder states.

🚨 Issue 7: Missing Gitea Actions Workflows due to .gitignore Exclusions

  • Symptom: After the first successful push, the Actions configuration tab was empty, displaying There are no workflows yet.
  • Cause: The .gitignore configuration mistakenly contained a rule to exclude the .gitea/ directories. Consequently, our deployment scripts under .gitea/workflows/deploy.yml were silently ignored during git staging.
  • Resolution: Removed the .gitea/ exclusion rule from .gitignore, staged the directories using git add .gitea/, and re-committed the workspace to activate the workflow pipeline.

🚨 Issue 8: 404 API Fallback Checkout Failure via Slim Images

  • Symptom: The pipeline failed on the Checkout Source Code step, throwing a 404 page not found error.
  • Cause: The runner's default build environment (node:22-slim) lacked git binaries. Lacking git command support, actions/checkout defaulted to using the Gitea REST API to fetch a zip archive (/tarball). However, since Gitea's internal ROOT_URL did not align with internal network header routing, the API call returned a secure 404 Not Found error.
  • Resolution: Updated docker-compose.gitea.yml to specify the standard node:22 image (which has git pre-installed) for the runner labels. Reset the runner's credentials by deleting .runner and restarted the container. The checkout step then executed a direct git clone successfully.

🚨 Issue 9: Missing Docker CLI in Build Environment and 3-Second Static Binary Fix

  • Symptom: The build step threw docker: command not found (exitcode 127).
  • Cause: While the host's /var/run/docker.sock was mounted, the runner's container lacked the docker CLI command binary. Running a full apt-get installation for docker tools on every build adds 30+ seconds of overhead, violating our FinOps optimization principles.
  • Resolution: Configured a lightweight setup step in deploy.yml that fetches official Docker and Compose static binaries via curl. Setting up the CLI takes only 3 seconds, keeping the build pipeline exceptionally fast.

🚨 Issue 10: Missing .env Configurations on Compose Mount Initialization

  • Symptom: The compose execution failed with invalid spec: empty section between colons.
  • Cause: To protect private keys, the .env configuration file was excluded from git. Lacking environment mappings in the build container, the host docker engine received empty values for compose volume paths.
  • Resolution: Encrypted and saved the .env settings inside Gitea's repository Secrets. Added a setup step in .gitea/workflows/deploy.yml to dynamically recreate the infra/.env config right before rebuilding the containers.

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.
  • 7-Second Deployment Lead Time: Pushing code updates triggers the local agent immediately, completing target rebuilds in 7.0 seconds with zero human interaction, thanks to our 3-second Docker CLI binary setup.

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)