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
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
📄 .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!"
4. Real-World Troubleshooting
🚨 Issue 1: Runner Loopback Failure via External Domain Addresses
-
Symptom: Pointing
GITEA_INSTANCE_URLtohttps://git.your-domain.comthrew 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.sockduring deployment steps. -
Cause: The host's socket requires
rootordockergroup membership. The runner's inner container user (UID 1000) was not mapped to the host'sdockerGID (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
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 psrevealed 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-internalnetwork. By default, the Docker daemon drops and blocks all host port forwarding rules for containers isolated purely insideinternal: truebridge networks for strict access segregation. -
Resolution: Map both
ainews-internaland the public bridge networkainews-externalto Gitea indocker-compose.gitea.yml:
networks:
- ainews-internal
- ainews-external # <- Allow host port forwarding by joining public network
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 emptyeven though we mapped the token in the configuration. -
Cause: Depending on the specific image tag, Gitea Act Runner expects either
GITEA_RUNNER_TOKENorGITEA_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
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)