[{"data":1,"prerenderedAt":648},["ShallowReactive",2],{"page:\u002Fblog\u002Ffighting-github-ci-bills-with-my-own-homelab":3},{"_tag":4,"page":5,"styles":647},"Ok",{"id":6,"path":7,"stem":8,"extension":9,"title":10,"description":11,"publishedAt":12,"status":13,"aside":14,"tags":15,"newsletter":14,"prose":19,"breadcrumbs":19,"h1":19,"wide":14,"seo":20,"_source":21,"sitemap":22,"body":23},"pages\u002Fblog\u002Ffighting-github-ci-bills-with-my-own-homelab.md","\u002Fblog\u002Ffighting-github-ci-bills-with-my-own-homelab","blog\u002Ffighting-github-ci-bills-with-my-own-homelab","md","Fighting GitHub CI bills with my own homelab","Moving GitHub Actions onto Hogwild, with live runner cost estimates, memory limits and the homelab maintenance that followed.","2026-09-11","unlisted",false,[16,17,18],"github-actions","homelab","development",true,{"title":10,"description":11},"\u002Fhome\u002Frunner\u002Fwork\u002Fharlanzw.com\u002Fharlanzw.com\u002Fcontent\u002Fblog\u002Ffighting-github-ci-bills-with-my-own-homelab.md",{"loc":7},{"frontmatter":24,"meta":26,"nodes":54,"toc":638},{"title":10,"description":11,"publishedAt":12,"status":13,"aside":14,"tags":25,"newsletter":14},[16,17,18],{"description":27,"toc":28},"Getting agents to write more code gave me more CI to run.",{"title":29,"searchDepth":30,"depth":30,"links":31},"",3,[32,36,39,42,45,48,51],{"id":33,"depth":34,"text":35},"what-the-runners-would-have-cost",2,"What the runners would have cost",{"id":37,"depth":34,"text":38},"the-computer","The computer",{"id":40,"depth":34,"text":41},"what-about-electricity","What about electricity?",{"id":43,"depth":34,"text":44},"one-container-one-job","One container, one job",{"id":46,"depth":34,"text":47},"the-first-budget-was-ram","The first budget was RAM",{"id":49,"depth":34,"text":50},"giving-an-agent-the-right-keys","Giving an agent the right keys",{"id":52,"depth":34,"text":53},"i-own-the-failures-now","I own the failures now",[55,58,61,64,67,70,73,76,79,82,90,93,96,276,284,286,289,292,295,308,310,313,320,328,331,334,337,340,343,345,352,355,358,361,466,473,476,478,481,484,487,518,521,524,531,534,536,539,542,553,581,584,598,605,608,611,614,616,623,626,629,632,635],[56,57,27],"p",{},[56,59,60],{},"Every proposed fix needed checks. Review found something, another commit arrived, and the checks ran again. Multiply that by several projects and a service that keeps working after I've closed my laptop.",[56,62,63],{},"I wanted that work off my GitHub bill. I also wanted it off my desktop.",[56,65,66],{},"So I bought a small computer, named it Hogwild, and gave myself a second job maintaining it.",[68,69,35],"h2",{"id":33},[56,71,72],{},"This reads the completed job totals from Hogwild. The amount updates as the runner records more work.",[74,75],"article-runner-savings",{},[56,77,78],{},"I call this estimated hosted cost avoided because I haven't reconciled it against an invoice. It prices the recorded runtime at GitHub's standard Linux x64 rate. A job on GitHub's machine may take a different amount of time.",[56,80,81],{},"The history also includes earlier self-hosted runner work, before the move to Hogwild. It isn't all work this new machine completed.",[56,83,84,89],{},[85,86,88],"a",{"href":87},"https:\u002F\u002Fdocs.github.com\u002Fen\u002Fbilling\u002Fconcepts\u002Fproduct-billing\u002Fgithub-actions","GitHub includes hosted minutes for private repositories, and standard hosted runners are free for public repositories",". My open-source projects already benefit from that. Private project builds were the reason to move more work home.",[56,91,92],{},"The calculation rounds each job up to a whole minute before adding the total. Two jobs lasting 61 seconds each count as four minutes. Rounding their combined runtime would count three.",[56,94,95],{},"Here's the equivalent TypeScript for the calculation used by the runner's job history:",[97,98,101],"pre",{"language":99,"class":100},"ts","rangi shiki shj-lang-ts",[102,103,105,126,127,126,139,126,146,126,154,126,191,126,196,126,198,126,228,126,241,126,259],"code",{"class":104},"language-ts",[106,107,110,115,116,121],"span",{"class":108,"style":109},"line","display: inline",[106,111,114],{"class":112,"style":113},"shj-syn-kwd shj-kwd","--shiki-light:#cf222e;--shiki-default:#cf222e;color:#cf222e;--shiki-dark:#ff7b72","function"," ",[106,117,120],{"class":118,"style":119},"shj-syn-func shj-func","--shiki-light:#8250df;--shiki-default:#8250df;color:#8250df;--shiki-dark:#d2a8ff","billableMinutes",[106,122,125],{"class":123,"style":124},"shj-syn-bracket shj-bracket","--shiki-light:#57606a;--shiki-default:#57606a;color:#57606a;--shiki-dark:#8b949e","(","\n",[106,128,129,130,135],{"class":108,"style":109},"  startedAt",[106,131,134],{"class":132,"style":133},"shj-syn-type shj-type","--shiki-light:#0550ae;--shiki-default:#0550ae;color:#0550ae;--shiki-dark:#79c0ff",": number",[106,136,138],{"class":137,"style":113},"shj-syn-oper shj-oper",",",[106,140,141,142,144],{"class":108,"style":109},"  completedAt",[106,143,134],{"class":132,"style":133},[106,145,138],{"class":137,"style":113},[106,147,148,115,151],{"class":108,"style":109},[106,149,150],{"class":123,"style":124},")",[106,152,153],{"class":123,"style":124},"{",[106,155,156,157,115,160,165,168,171,173,175,176,179,180,115,182,115,185,189],{"class":108,"style":109},"  ",[106,158,159],{"class":112,"style":113},"return",[106,161,164],{"class":162,"style":163},"shj-syn-class shj-class","--shiki-light:#953800;--shiki-default:#953800;color:#953800;--shiki-dark:#ffa657","Math",[106,166,167],{"class":137,"style":113},".",[106,169,170],{"class":118,"style":119},"ceil",[106,172,125],{"class":123,"style":124},[106,174,125],{"class":123,"style":124},"completedAt ",[106,177,178],{"class":137,"style":113},"-"," startedAt",[106,181,150],{"class":123,"style":124},[106,183,184],{"class":137,"style":113},"\u002F",[106,186,188],{"class":187,"style":133},"shj-syn-num shj-num","60_000",[106,190,150],{"class":123,"style":124},[106,192,193],{"class":108,"style":109},[106,194,195],{"class":123,"style":124},"}",[106,197],{"class":108,"style":109},[106,199,200,203,204,207,208,210,213,215,217,218,220,221,115,223,115,226],{"class":108,"style":109},[106,201,202],{"class":112,"style":113},"const"," hostedCostUsd ",[106,205,206],{"class":137,"style":113},"="," jobs",[106,209,167],{"class":137,"style":113},[106,211,212],{"class":118,"style":119},"reduce",[106,214,125],{"class":123,"style":124},[106,216,125],{"class":123,"style":124},"total",[106,219,138],{"class":137,"style":113}," job",[106,222,150],{"class":123,"style":124},[106,224,225],{"class":112,"style":113},"=>",[106,227,153],{"class":123,"style":124},[106,229,156,230,115,232,179,234,236,237,115,239,220],{"class":108,"style":109},[106,231,202],{"class":112,"style":113},[106,233,153],{"class":123,"style":124},[106,235,138],{"class":137,"style":113}," completedAt ",[106,238,195],{"class":123,"style":124},[106,240,206],{"class":137,"style":113},[106,242,156,243,245,246,115,249,251,253,254,256,257],{"class":108,"style":109},[106,244,159],{"class":112,"style":113}," total ",[106,247,248],{"class":137,"style":113},"+",[106,250,120],{"class":118,"style":119},[106,252,125],{"class":123,"style":124},"startedAt",[106,255,138],{"class":137,"style":113}," completedAt",[106,258,150],{"class":123,"style":124},[106,260,261,263,115,265,268,115,270,115,273],{"class":108,"style":109},[106,262,195],{"class":123,"style":124},[106,264,138],{"class":137,"style":113},[106,266,267],{"class":187,"style":133},"0",[106,269,150],{"class":123,"style":124},[106,271,272],{"class":137,"style":113},"*",[106,274,275],{"class":187,"style":133},"0.006",[56,277,278,279,283],{},"The ",[85,280,282],{"href":281},"https:\u002F\u002Fdocs.github.com\u002Fen\u002Fbilling\u002Freference\u002Factions-runner-pricing","US$0.006 rate"," is for GitHub's standard two-core Linux x64 runner, checked in September 2026. This gives me a consistent comparison. It doesn't tell me when the computer pays for itself.",[68,285,38],{"id":37},[56,287,288],{},"Hogwild is a Minisforum MS-02 Ultra with an Intel Core Ultra 9 285HX. I bought a 2 TB Kingston KC3000 SSD and 32 GB of Crucial DDR5 RAM for it.",[290,291],"article-hogwild-hardware",{},[56,293,294],{},"Those are my purchase figures in Australian dollars. The runner estimate above is in US dollars. I haven't measured electricity use well enough to add a credible running cost.",[56,296,297,298,302,303,307],{},"The machine also runs ",[85,299,301],{"href":300},"\u002Fblog\u002Fbuilding-my-software-factory-on-github","my GitHub agent",", the ",[85,304,306],{"href":305},"https:\u002F\u002Fhogwild.harlanzw.com\u002F","Hogwild status page",", and services like Jellyfin. Charging the entire purchase to CI would be a choice, too.",[68,309,41],{"id":40},[56,311,312],{},"I don't have a wall-meter reading for Hogwild yet. CPU utilisation can't tell me what the whole machine draws.",[56,314,315,319],{},[85,316,318],{"href":317},"https:\u002F\u002Fwww.servethehome.com\u002Fminisforum-ms-02-ultra-review-intel-new-home-lab-king\u002F4\u002F","ServeTheHome measured roughly 19–21 W at idle and 145 W under sustained load"," on an MS-02 Ultra. My services, operating system and network cards can change that. I've used 50 W average as a scenario, rather than treating an idle measurement as a day of CI.",[56,321,322,323,327],{},"At 50 W, running around the clock uses 438 kWh a year. I use 28.2¢\u002FkWh as a local benchmark, the simple average of the ",[85,324,326],{"href":325},"https:\u002F\u002Fwww.esc.vic.gov.au\u002Fsites\u002Fdefault\u002Ffiles\u002Fdocuments\u002FVictorian%20Default%20Offer%202026-27%20Price%20Determination.pdf","five Victorian Default Offer residential flat tariffs for 2026–27",", including GST. That puts electricity at about A$124 a year.",[56,329,330],{},"This is a tariff benchmark, rather than a measured average of Melbourne household bills. It excludes the daily supply charge I already pay to connect my house.",[332,333],"article-runner-projection",{},[56,335,336],{},"The projection uses the last seven complete days of runner activity. Today's partial total stays out of the average. Seven days is a short sample, so a busy release week can make the result look much better than a quiet month.",[56,338,339],{},"The chart charges the full A$2,773 purchase to CI, converts hosted charges to AUD, then adds five years of electricity. It also subtracts an allowance for included GitHub minutes. The controls let you change the allowance and exclude work that would already run free on GitHub.",[56,341,342],{},"I wouldn't buy the machine on this projection alone. I want to see a few months of work and a measured power average before calling it my actual saving.",[68,344,44],{"id":43},[56,346,347,348,351],{},"The runner supervisor watches the configured repositories for queued work. A signed ",[102,349,350],{},"workflow_job"," webhook can wake it early; polling catches work if that notification doesn't arrive.",[56,353,354],{},"Before it registers a runner, it checks whether the host has capacity. Then it creates a just-in-time runner in a fresh container. The runner takes one job and exits.",[56,356,357],{},"The job container runs as a non-root user. It gets no Docker socket or host filesystem mounts. The supervisor creates the registration configuration on the host and passes it through standard input.",[56,359,360],{},"That leaves the workflow change fairly small. A job selects one of the labels my supervisor serves:",[97,362,365],{"language":363,"class":364},"yaml","rangi shiki shj-lang-yaml",[102,366,368,126,377,126,384,126,408,126,415,126,426,126,436,126,453],{"class":367},"language-yaml",[106,369,370,374],{"class":108,"style":109},[106,371,373],{"class":372,"style":133},"shj-syn-var shj-var","jobs",[106,375,376],{"class":137,"style":113},":",[106,378,156,379,382],{"class":108,"style":109},[106,380,381],{"class":372,"style":133},"test",[106,383,376],{"class":137,"style":113},[106,385,386,387,390,115,392,395,396,398,399,401,402,404,405],{"class":108,"style":109},"    ",[106,388,389],{"class":372,"style":133},"runs-on",[106,391,376],{"class":137,"style":113},[106,393,394],{"class":123,"style":124},"[","self",[106,397,178],{"class":137,"style":113},"hosted, harlan",[106,400,178],{"class":137,"style":113},"desktop",[106,403,178],{"class":137,"style":113},"ci",[106,406,407],{"class":123,"style":124},"]",[106,409,386,410,413],{"class":108,"style":109},[106,411,412],{"class":372,"style":133},"steps",[106,414,376],{"class":137,"style":113},[106,416,417,418,115,420,423,425],{"class":108,"style":109},"      ",[106,419,178],{"class":137,"style":113},[106,421,422],{"class":372,"style":133},"uses",[106,424,376],{"class":137,"style":113}," actions\u002Fcheckout@v4",[106,427,417,428,115,430,433,435],{"class":108,"style":109},[106,429,178],{"class":137,"style":113},[106,431,432],{"class":372,"style":133},"run",[106,434,376],{"class":137,"style":113}," corepack enable",[106,437,417,438,115,440,442,444,445,447,449,450,452],{"class":108,"style":109},[106,439,178],{"class":137,"style":113},[106,441,432],{"class":372,"style":133},[106,443,376],{"class":137,"style":113}," pnpm install ",[106,446,178],{"class":137,"style":113},[106,448,178],{"class":137,"style":113},"frozen",[106,451,178],{"class":137,"style":113},"lockfile",[106,454,417,455,115,457,459,461,462,464,432],{"class":108,"style":109},[106,456,178],{"class":137,"style":113},[106,458,432],{"class":372,"style":133},[106,460,376],{"class":137,"style":113}," pnpm ",[106,463,381],{"class":372,"style":133},[106,465,376],{"class":137,"style":113},[56,467,468,469,472],{},"That's a shortened example, assuming the runner image already has the project's Node version. The ",[102,470,471],{},"harlan-desktop-ci"," label survived the move. I haven't renamed it yet.",[56,474,475],{},"Fresh containers help keep one job's files out of the next job. I still have to maintain the runner image and decide which repositories may execute code on my hardware.",[68,477,47],{"id":46},[56,479,480],{},"An earlier desktop runner measurement put a Nuxt SEO deploy at 10.7 GiB of memory. A few builds arriving together could consume the host before CPU became the problem.",[56,482,483],{},"The supervisor now reserves memory before starting a job. A reservation spends part of the host's available capacity. A container limit caps how much that individual job can use.",[56,485,486],{},"Here is a row from Hogwild's runner configuration, wrapped for reading:",[97,488,491],{"language":489,"class":490},"text","rangi shiki shj-lang-text",[102,492,494,126,497,126,500,126,503,126,506,126,509,126,512,126,515],{"class":493},"language-text",[106,495,496],{"class":108,"style":109},"repository:          harlan-zw\u002Fnuxtseo.com",[106,498,499],{"class":108,"style":109},"labels:              harlan-desktop-deploy,nuxtseo-deploy",[106,501,502],{"class":108,"style":109},"warm runners:        0",[106,504,505],{"class":108,"style":109},"maximum runners:     1",[106,507,508],{"class":108,"style":109},"CPUs:                12",[106,510,511],{"class":108,"style":109},"memory reservation:  13g",[106,513,514],{"class":108,"style":109},"memory limit:        16g",[106,516,517],{"class":108,"style":109},"memory plus swap:    20g",[56,519,520],{},"The reservation and limit deliberately differ. The supervisor budgets 13 GiB before accepting the deploy, while the container has room to exceed that estimate.",[56,522,523],{},"I also give waiting deploys the next available capacity. Otherwise a steady stream of smaller checks can keep taking the space a deploy needs.",[56,525,526,527,530],{},"Getting the limits wrong produces misleading failures. A ",[102,528,529],{},"gscdump.com"," cold build measured 7.82 GiB, close enough to its old 8 GiB limit that the container could kill it mid-bundle. The same commit passed on the desktop, where the job had more memory.",[56,532,533],{},"I had to fix the runner configuration before another code change could tell me anything useful.",[68,535,50],{"id":49},[56,537,538],{},"Hogwild runs an agent that reads public issue text. It also runs a supervisor that can create containers. I don't want those to share an account with the same permissions.",[56,540,541],{},"The account split gives the agent ordinary user access. A separate admin account owns host administration and Docker access.",[56,543,544,548,549,552],{},[85,545,547],{"href":546},"https:\u002F\u002Fgithub.com\u002Fharlan-zw\u002Fharlan-agent-kit\u002Fpull\u002F250","PR #250 in Harlan Agent Kit"," adds a Hogwild skill and an ",[102,550,551],{},"hw"," command to make that distinction explicit. The proposed commands include:",[97,554,557],{"language":555,"class":556},"sh","rangi shiki shj-lang-sh",[102,558,560,126,565,126,576],{"class":559},"language-sh",[106,561,562,564],{"class":108,"style":109},[106,563,551],{"class":118,"style":119}," status",[106,566,567,569,570,115,573],{"class":108,"style":109},[106,568,551],{"class":118,"style":119}," logs runner",[106,571,572],{"class":112,"style":113}," -n",[106,574,575],{"class":187,"style":133},"100",[106,577,578,580],{"class":108,"style":109},[106,579,551],{"class":118,"style":119}," runners",[56,582,583],{},"For commands on the machine, the caller must choose an account:",[97,585,586],{"language":555,"class":556},[102,587,588,126,593],{"class":559},[106,589,590,592],{"class":108,"style":109},[106,591,551],{"class":118,"style":119}," run agent whoami",[106,594,595,597],{"class":108,"style":109},[106,596,551],{"class":118,"style":119}," run admin whoami",[56,599,600,601,604],{},"The agent route refuses ",[102,602,603],{},"sudo",". The useful part of that PR is the account map: a failed privileged command should send administrative work to the admin account. It shouldn't become a reason to give the agent more permissions.",[56,606,607],{},"That is still a proposed change as I write this. The pull request has the implementation and review history.",[56,609,610],{},"The live figures in this article use a separate read path. Hogwild's collector reads local services and returns a small summary to this site's server, authenticated with a dedicated token. The browser gets the summary. It never gets the agent's dashboard password.",[56,612,613],{},"For the homepage's latest work, the server checks each reference against GitHub without authentication before exposing a title or link. Private work stays out of that list.",[68,615,53],{"id":52},[56,617,618,619,622],{},"On 10 September, a build stalled fetching a font from ",[102,620,621],{},"fonts.gstatic.com",". Docker was trying a DNS server that the firewall blocked. The delay then collided with the HTTP client's timeout.",[56,624,625],{},"There was nothing wrong with the font import. There was something wrong with my network.",[56,627,628],{},"Runner updates are another small tax. The runner version in the image can fall behind GitHub's version, leaving each fresh runner to download an update before doing useful work. Updating the image removes that repeated setup.",[56,630,631],{},"Even restarting the supervisor needs care. It drains running jobs before stopping. I have to leave it time to finish, or decide that interrupting those builds is worth it.",[56,633,634],{},"I still use GitHub-hosted runners where they make sense. Hogwild gives the private builds somewhere else to run, and I can see their memory use and cost comparison on a page I control.",[56,636,637],{},"I still need to put a meter on Hogwild. That will replace the largest electricity assumption in the chart.",{"title":29,"searchDepth":30,"depth":30,"links":639},[640,641,642,643,644,645,646],{"id":33,"depth":34,"text":35},{"id":37,"depth":34,"text":38},{"id":40,"depth":34,"text":41},{"id":43,"depth":34,"text":44},{"id":46,"depth":34,"text":47},{"id":49,"depth":34,"text":50},{"id":52,"depth":34,"text":53},[],1789145440043]