Back to All Articles
DevOps•
8 min read

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.

S
Siegfried Stehring•
Migrating from GitHub Actions to Gitea Actions on Proxmox: True Air-Gapped GitOps

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:

  1. Open an inbound SSH or webhook port on your public firewall.
  2. Maintain a reverse VPN tunnel (e.g., Cloudflare Tunnel, Tailscale, or WireGuard) into your local subnet.
  3. 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 labeled runs-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:

  1. Source Code Extraction: Check out the latest commit on main.
  2. 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).
  3. Container Rebuild & Orchestration: Execute Docker Compose build and restart in non-interactive background mode.
  4. 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!

Support on Ko-fi
Tags:#Gitea#CI/CD#Proxmox#GitOps#Docker#DevOps#Self-Hosting#Automation

Related Engineering Guides

Architecture7 min read

Architecture of a Modern Self-Hosted Tech Blog: From Proxmox to Next.js and Git-Push CI/CD

A comprehensive architectural breakdown of how this blog was engineered from the bare metal up: Proxmox LXC containers, Docker host networking, privacy-first local analytics, and a zero-cloud GitOps CI/CD pipeline.

Read Article
DevOps1 min read

Mastering Self-Hosting: Docker, Nginx, and Home Lab Best Practices

A practical step-by-step guide to running containerized web applications at home or on private VPS nodes securely with reverse proxies and SSL.

Read Article
Architecture7 min read

Hardening Linux Containers: Rootless Docker Inside Unprivileged Proxmox LXC

A deep-dive systems engineering guide to nesting Docker securely inside unprivileged Proxmox LXC containers: mastering subuid/subgid user namespace remapping, storage driver performance over ZFS datasets, AppArmor profiles, and solving the dreaded bind-mount permission trap.

Read Article