Migrating from GitHub Actions to Gitea Actions on Proxmox: True Air-Gapped GitOps
How we migrated our entire continuous deployment pipeline from GitHub Actions to self-hosted Gitea Actions running on a Proxmox LXC runner: achieving zero-cloud dependency, sub-minute builds, secure private network deployments, and persistent state management.
For many years, GitHub Actions was the undisputed default CI/CD engine for indie developers, enterprise teams, and homelabbers alike. It provides seamless YAML configuration, extensive marketplace actions, and free tier minutes.
However, as a self-hosted infrastructure matures, relying on an external cloud runner to build and deploy workloads inside a private local network creates a glaring contradiction. Why build a sovereign, private Proxmox environment only to route internal deployment credentials, SSH keys, and container builds through Microsoft's cloud?
When our Git repository migrated to a self-hosted Gitea instance on our private VLAN (10.100.0.14), migrating our continuous deployment pipeline to Gitea Actions powered by the act_runner daemon was the natural next step.
Here is an architectural walkthrough of how we transitioned from GitHub Actions to self-hosted Gitea Actions on Proxmox VE, the security benefits gained, and how to configure a bulletproof pipeline that deploys containerized applications with zero external cloud dependencies.
1. The Architectural Shift: Cloud Runners vs. Local GitOps
Let us first examine the network topography of using GitHub Actions to deploy to an internal network versus running a native Gitea pipeline.
The Problem: Cloud Runner Ingress
When using GitHub Actions, the build runner executes on Azure compute. To deliver files or run Docker commands on a home server or private office node, you are forced to:
- Open an inbound SSH or webhook port on your public firewall.
- Maintain a reverse VPN tunnel (e.g., Cloudflare Tunnel, Tailscale, or WireGuard) into your local subnet.
- Expose high-privilege deployment credentials to third-party cloud storage.
[ Developer Machine ]
│ git push
▼
[ GitHub Cloud ] ─────────► [ GitHub Actions Runner (Azure) ]
│
▼ (Traversing WAN / Firewall)
[ Inbound Port / Tunnel ]
│
▼
[ Private Target Host (10.100.0.4) ]
The Solution: Local Air-Gapped GitOps
With Gitea and Gitea Actions running directly inside your Proxmox cluster:
- Source code, secrets, runners, and deployment targets live on the same high-speed internal bridge (
10.100.0.0/24). - Builds run at local bus and SSD speeds without WAN bandwidth limits or queuing delays.
- Zero inbound ports exist on your router.
- The pipeline functions with 100% fidelity even if your external internet connection drops completely.
======================= PRIVATE PROXMOX SUBNET (10.100.0.0/24) =======================
[ Developer ]
│ git push (LAN)
▼
[ Gitea Server (10.100.0.14) ]
│ Webhook trigger (internal)
▼
[ Gitea Act Runner (LXC Host / Daemon) ]
│ Fast rsync sync & docker build (10 Gbps veth)
▼
[ Target Application Container: self_hosted_blog (10.100.0.4:3000) ]
======================================================================================
2. Setting Up the Gitea Actions Runner on Proxmox LXC
Gitea Actions uses act_runner, a standalone daemon written in Go that executes GitHub Actions-compatible YAML workflows using Docker or directly on the host system.
Container Configuration
We provision an unprivileged Debian 12 LXC container on Proxmox VE dedicated to the runner daemon. In container options, enable:
features: nesting=1(required for running nested containers or Docker socket access).
Inside the LXC container, install Docker Engine and download the official act_runner binary:
# Update and install Docker
apt update && apt install -y curl git docker.io
# Download the latest act_runner release
curl -fsSL https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64 -o /usr/local/bin/act_runner
chmod +x /usr/local/bin/act_runner
# Register the runner with your Gitea instance
act_runner register \
--instance http://10.100.0.14:3000 \
--token <YOUR_REGISTRATION_TOKEN_FROM_GITEA_ADMIN> \
--no-interactive \
--labels ubuntu-latest:docker://node:20-bookworm,host:host
Notice the --labels argument:
ubuntu-latest:docker://node:20-bookworm: Runs steps inside clean Docker containers.host:host: Allows tasks labeledruns-on: [host]to run directly on the runner host with full access to native utilities (rsync,ssh,curl,chown).
Systemd Service Setup
Ensure the daemon runs continuously on boot:
# /etc/systemd/system/act_runner.service
[Unit]
Description=Gitea Action Runner Daemon
After=network.target docker.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/act_runner
ExecStart=/usr/local/bin/act_runner daemon
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now act_runner
Within seconds, the runner appears under Gitea Site Administration → Actions → Runners with a green active indicator.
3. Engineering the Production Deployment Workflow
Our deployment workflow needs to accomplish four critical tasks:
- Source Code Extraction: Check out the latest commit on
main. - Selective State-Preserving Sync: Synchronize application code to the production container host (
10.100.0.4:/opt/blog) while strictly preserving persistent runtime data (markdown posts, media uploads, MFA secrets, and local visitor telemetry JSON files). - Container Rebuild & Orchestration: Execute Docker Compose build and restart in non-interactive background mode.
- Automated Health Verification: Poll internal HTTP endpoints to verify that the new build is healthy before declaring the pipeline run successful.
Here is the exact .gitea/workflows/deploy.yaml specification running this blog:
name: Deploy Blog to LXC Container
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
name: Deploy Next.js Blog Stack
runs-on: [host]
env:
TARGET_IP: "10.100.0.4"
TARGET_PATH: "/opt/blog"
HEALTH_URL: "http://10.100.0.4:3000/api/posts"
steps:
- name: Checkout Source Code
uses: actions/checkout@v4
- name: Sync Files to Container via Rsync
run: |
echo "🚀 Syncing Blog files to $TARGET_IP:$TARGET_PATH..."
rsync -avz --delete \
--exclude '.git' \
--exclude '.gitea' \
--exclude '.github' \
--exclude '.env' \
--exclude 'node_modules' \
--exclude '.next' \
--exclude 'public/uploads' \
--exclude 'content/analytics.json' \
--exclude 'content/mfa.json' \
--exclude 'content/*.json' \
./ root@${TARGET_IP}:${TARGET_PATH}/
- name: Build & Restart Docker Stack
run: |
echo "🐳 Rebuilding and starting Docker container on $TARGET_IP..."
ssh -o StrictHostKeyChecking=accept-new root@${TARGET_IP} "\
cd ${TARGET_PATH} && \
mkdir -p public/uploads content/posts && \
touch content/analytics.json content/mfa.json 2>/dev/null || true && \
chown -R 1001:1001 content public/uploads && \
chmod -R 775 content public/uploads && \
[ ! -f .env ] && cp .env.example .env || true && \
docker compose down && docker compose up -d --build --remove-orphans"
- name: Health Check
run: |
echo "🔍 Verifying application health at $HEALTH_URL..."
for i in {1..30}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL" || echo "000")
if [ "$STATUS" -eq 200 ]; then
echo "✅ Next.js Blog is UP and healthy (HTTP $STATUS)!"
exit 0
fi
echo "Waiting for service... (attempt $i/30, status: $STATUS)"
sleep 2
done
echo "❌ Deployment health check timed out after 60 seconds."
exit 1
4. Key Lessons & Gotchas Learned in Production
1. State Preservation Over rsync --delete
The --delete flag in rsync is fantastic for cleaning up orphaned build artifacts. But if you fail to explicitly exclude persistent runtime data directories (public/uploads, content/analytics.json, content/mfa.json), rsync will happily vaporize your production databases on the first deployment. Always explicitly exclude stateful directories from the sync tree.
2. User Namespace Permission Mappings (The UID 1001 Trap)
In containerized Next.js applications, production images drop root privileges and execute as an unprivileged user (UID 1001, GID 1001). If files synced via root SSH arrive on the target filesystem owned by root:root with default 755 permissions, the Next.js process will crash with EACCES: permission denied the instant it tries to write an uploaded image or update frontmatter.
The pipeline explicitly executes:
chown -R 1001:1001 content public/uploads
chmod -R 775 content public/uploads
This guarantees read and write access for the Next.js process while maintaining host-level security.
3. Avoiding Docker Container Name Conflicts
When running Docker Compose inside CI scripts, executing docker compose up -d --build without a preceding docker compose down can intermittently produce Docker daemon name-conflict race conditions (Conflict. The container name is already in use). Structuring the step as:
docker compose down && docker compose up -d --build --remove-orphans
ensures that the preceding container instance gracefully shuts down and unbinds its network socket before the freshly built image starts up.
5. Performance Comparison
| Metric | GitHub Actions (Cloud) | Gitea Actions (Local Proxmox LXC) | Improvement | | :--- | :--- | :--- | :--- | | Queue Latency | 15 – 45 seconds | Instant (< 1 second) | ~98% faster kickoff | | Checkout & Dependency Sync | 40 – 60 seconds (over WAN) | 4 seconds (Local SSD & LAN) | 10x speedup | | Docker Build Cache | Often invalidated on ephemeral VMs | Persistent host cache | 3x faster build step | | Total Deploy Time | 2m 30s – 4m 15s | 38 seconds | ~5x faster turnaround | | WAN Outage Resilience | ❌ Completely down | ✅ 100% operational | Full air-gapped sovereignty |
Conclusion
Migrating to Gitea Actions closes the loop on self-hosted infrastructure. By keeping source code, runners, build artifacts, and deployment targets inside your own hardware boundary, you gain faster build cycles, tighter security controls, and true operational autonomy.
Support Independent Technical Content
If this article helped you build, debug, or host your applications, consider supporting the blog on Ko-fi. Every cup of coffee helps keep this site ad-free and 100% self-hosted!