Tech Expert & Vibe Coder

With 14+ years of experience, I specialize in self-hosting, AI automation, and Vibe Coding – building applications using AI-powered tools like Google Antigravity, Dyad, and Cline. From homelabs to enterprise solutions.

Setting Up Self-Hosted OpenWorkers Runtime on Proxmox LXC with Automated GitHub Actions Deployment Pipeline

Why I Built This

I needed a way to run GitHub Actions jobs on my own infrastructure instead of GitHub's hosted runners. The main drivers were:

  • Control over the execution environment
  • Access to my internal network and services
  • Cost savings for heavy CI/CD workloads
  • Faster builds with local caching

I already run Proxmox for my homelab, so spinning up dedicated LXC containers for GitHub runners made sense. The challenge was making them ephemeral—each job gets a clean container that's destroyed after use.

My Setup

I'm running Proxmox VE 8.2 on a single node with 64GB RAM and plenty of storage. For this setup, I used:

  • Ubuntu 23.10 LXC template as the base
  • Privileged containers (required for Docker support)
  • GitHub Personal Access Token with repo scope
  • A private repository where I control all contributors

The workflow: a container boots, registers as an ephemeral runner, executes one job, then gets destroyed. No state carries over between runs.

Initial Container Creation

I started with a script I found that automates LXC creation for GitHub runners. The core steps it handles:

  1. Creates an LXC container from Ubuntu template
  2. Installs Docker (which is why the container must be privileged)
  3. Downloads and configures GitHub Actions runner
  4. Registers the runner in ephemeral mode
  5. Sets up the runner as a systemd service

Here's the critical part of my configuration:

CTID=200
HOSTNAME="github-runner-template"
STORAGE="local-lvm"
TEMPLATE="ubuntu-23.10-standard"
MEMORY=4096
CORES=4
GITHUB_TOKEN="ghp_xxxxxxxxxxxxx"
OWNER_REPO="vipinpg/my-repo"

The container ID 200 became my template. I don't run this directly for jobs—it's a base I clone from.

The Privileged Container Problem

Docker inside LXC requires a privileged container. This breaks the security isolation that makes LXC attractive in the first place. A compromised runner could potentially affect the host.

I accepted this trade-off because:

  • The repository is private
  • I control all code that runs
  • Each container is destroyed after one job
  • My Proxmox node isn't exposed to the internet

If I were running this for public repositories or untrusted code, I would use full VMs instead. The security boundary matters more than the performance gain.

Automation with GitHub Actions

The script creates one container, but I needed automation to:

  1. Detect when a GitHub Actions job is queued
  2. Clone the template container
  3. Start it and let it pick up the job
  4. Destroy it after completion

GitHub doesn't provide a webhook for "job queued" events, so I built a polling mechanism using a small Python script running on the Proxmox host:

#!/usr/bin/env python3
import requests
import subprocess
import time

GITHUB_TOKEN = "ghp_xxxxxxxxxxxxx"
REPO = "vipinpg/my-repo"
TEMPLATE_CTID = 200
MAX_RUNNERS = 3

def get_queued_jobs():
    headers = {"Authorization": f"token {GITHUB_TOKEN}"}
    url = f"https://api.github.com/repos/{REPO}/actions/runs?status=queued"
    response = requests.get(url, headers=headers)
    return len(response.json()["workflow_runs"])

def clone_and_start_runner(new_ctid):
    subprocess.run(["pct", "clone", str(TEMPLATE_CTID), str(new_ctid)])
    subprocess.run(["pct", "start", str(new_ctid)])

while True:
    queued = get_queued_jobs()
    running = int(subprocess.check_output(["pct", "list"]).decode().count("running"))
    
    if queued > 0 and running < MAX_RUNNERS:
        new_id = int(subprocess.check_output(["pvesh", "get", "/cluster/nextid"]).decode().strip())
        clone_and_start_runner(new_id)
    
    time.sleep(30)

This polls every 30 seconds. Not elegant, but it works. I run it as a systemd service on the Proxmox host.

What Worked

  • Container cloning is fast—under 10 seconds to have a new runner ready
  • Ephemeral mode ensures no state pollution between jobs
  • Local network access lets me deploy to my internal services directly
  • Docker layer caching speeds up builds significantly

What Didn't Work

Initial approach with unprivileged containers: I tried setting up Docker in an unprivileged LXC first. It's technically possible with the right kernel features and configurations, but I hit permission issues with overlay filesystems. After two hours of troubleshooting, I switched to privileged containers.

Auto-cleanup: My first version didn't clean up stopped containers. After a week, I had 40+ stopped containers cluttering the node. I added a cron job to destroy any container with "github-runner" in the name that's been stopped for more than an hour:

#!/bin/bash
for ctid in $(pct list | grep "github-runner" | grep "stopped" | awk '{print $1}'); do
    pct destroy $ctid
done

Concurrent job handling: My polling script initially had no limit on runners. During a batch of commits, it spun up 15 containers and overwhelmed the node. I added MAX_RUNNERS to cap it at 3.

Integration with Existing Workflows

In my repository's workflow file, I specify the self-hosted runner:

name: Deploy
on: [push]

jobs:
  deploy:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v3
      - name: Build and deploy
        run: |
          docker build -t myapp .
          docker push myapp

GitHub doesn't care where the runner comes from. As long as it's registered and picks up jobs, it works exactly like a hosted runner.

Monitoring and Debugging

I use Proxmox's built-in metrics to watch resource usage. During active builds, I see:

  • CPU spikes to 80-90% per container
  • Memory usage around 2-3GB per job
  • Disk I/O peaks during Docker builds

For debugging failed jobs, I modified the cleanup script to preserve containers that exit with errors. I can then pct enter into them and inspect logs:

pct enter 205
cd /actions-runner
cat _diag/Runner_*.log

Cost and Performance

Before this setup, I was using GitHub's hosted runners. For my workload (about 50 builds per week), I was hitting the free tier limit and considering paid minutes.

With self-hosted runners:

  • No GitHub Actions minutes consumed
  • Builds run 2-3x faster due to local caching
  • Deployment to internal services is instant (no VPN needed)

The Proxmox node was already running, so the only additional cost is electricity—negligible.

Security Considerations

Running privileged containers for CI/CD is not ideal. Here's what I do to minimize risk:

  • Only use self-hosted runners for private repositories
  • All code is reviewed before merge
  • Containers have no persistent storage
  • The Proxmox node is on an isolated VLAN
  • GitHub tokens are scoped to specific repositories

If you're considering this for public repositories or untrusted contributors, don't. Use full VMs with proper isolation, or stick with GitHub's hosted runners.

Key Takeaways

  • LXC containers can work for GitHub Actions runners, but require privileged mode for Docker support
  • Ephemeral runners prevent state leakage between jobs
  • Polling GitHub's API for queued jobs works, but a proper webhook would be better
  • Container cleanup must be automated or you'll accumulate garbage
  • This setup only makes sense if you already run Proxmox and control the code being executed

The system has been running for three months. It's not perfect—the polling approach feels hacky, and I'd prefer a more event-driven architecture. But it works reliably, costs nothing beyond what I'm already spending, and gives me full control over the build environment.

If GitHub ever adds proper webhook support for job queuing, I'll rewrite the automation. Until then, this gets the job done.