There was an old laptop sitting on my desk doing almost nothing — a Dell XPS 9700, a 17-inch workstation I'd long since stopped carrying anywhere. Sixteen threads and 64 GB of RAM, plugged in, idle, basically an expensive paperweight with a keyboard. Free compute, right there in front of me.

Meanwhile my CI builds ran on a pool of VMs that were doing fine. The thing is, those VMs live on a colocated PowerEdge that's also running my personal Docker apps, the two-Tesla-P4 LLM stack I wrote about a few weeks ago, and a steady pile of general compute. The build VMs were never slow because the silicon was weak — they were slow when they were slow because they were time-sharing a busy host with everything else I ask that machine to do. Contention, not failure.

So this isn't a story about a server that fell over and got replaced. It's a trial. I had dedicated compute sitting idle on my desk, and I wanted to know one thing: what happens if I give CI a whole machine to itself, with nothing else fighting for the cores? The cheapest way to find out was to spend an afternoon trying — the same lesson I keep relearning every time I drag idle hardware into service.

This post is how that laptop became prb-bld-01, and the handful of laptop-specific gotchas that stood between "boots Ubuntu" and "a build node I'd actually trust."


The Box

The hardware is a Dell XPS 9700: an Intel i7-10875H (8 cores / 16 threads, 5.10 GHz single-core ceiling), 64 GB of RAM, an internal WD SN730 NVMe, and an RTX 2060 with 6 GB of VRAM. For a build box that's a genuinely strong spec — but the part that turned out to matter most wasn't the raw numbers. It was that the laptop would have the whole machine to itself, which my shared-host build VMs never did. The discrete GPU is a bonus I'll come back to.

It comes online as prb-bld-01, reached over a site-to-site VPN rather than Tailscale. The tunnel gives it the one thing it needs from the rest of my infrastructure — reach to git.svc, the internal Gitea host — and everything else it pulls (base images, packages) is public. The local model it serves is consumed on the box itself by CI jobs over localhost, so there's no overlay network to justify. If I ever want the GPU's LLM to become a shared endpoint like the M1 Studio, I'll add Tailscale then; until then, the VPN is enough.


Imaging

Before any automation touches it, the box gets a clean install.

I reimaged it to Ubuntu Server 26.04 LTS ("Resolute Raccoon"), installed onto the internal SN730. 26.04 is fresh — pre-26.04.1, so an early-adopter release in general — but I checked the one thing that actually gates my playbooks first: the Docker apt repo publishes the resolute codename, so the install resolves cleanly. (24.04 "Noble" remains a fine fallback if you'd rather not ride the new release.)

Two BIOS changes matter on a headless box:

  • Disable Secure Boot. On a machine I'll never sit in front of, Secure Boot just means the NVIDIA kernel module needs a MOK-enrollment dance at every driver bump — a one-time password typed into a blue firmware screen on the next reboot. That's fine on a desktop you're looking at; it's a trap on a box whose only interface is SSH. Off it goes.
  • Set the charge profile to Standard. More on this below — Dell's "Primarily AC Use" mode masks the reported battery charge, which makes the battery cap I want look like it isn't working.

Then I bring up the site-to-site VPN so the control node can SSH in and the box can reach git.svc, and add a DNS record for prb-bld-01 — internal records live in dns/blocky.yml as an A mapping, though the provisioning script can register it for me.


Making a Laptop Behave Like a Server

A laptop's defaults are all wrong for a server. The single most important one: close the lid and it suspends. A headless build node that goes to sleep the moment you tidy it onto a shelf is useless.

The fix is a small Ansible play (laptop-headless.yaml) that flips the box to lid-closed-always-on and masks the sleep and suspend targets outright, so nothing — not the lid switch, not an idle timeout, not a stray systemd inhibitor — can put it to sleep. After that the laptop is, functionally, a very flat 1U with a built-in UPS.

That built-in UPS is the next problem.


The Throttle Hunt

The first time I ran a real build on it, the clocks topped out around 3.3 GHz all-core. On a chip with a 5.10 GHz single-core ceiling, that felt deeply wrong — like the machine was leaving half its performance on the table. Chasing this down was the most interesting part of the whole project, mostly because every obvious suspect turned out to be innocent.

The CPU governor is a red herring. The reflex on Linux is to blame the powersave governor and switch it to performance. Don't bother. intel_pstate running powersave already uses the full turbo range — the governor mostly sets the idle floor, not the ceiling. Switching to performance raises how hard the chip works when it's doing nothing, which is the opposite of useful. I left it on powersave.

RAPL was never the limit. The other usual suspect is Intel's running-average power limit. I checked it:

cat /sys/class/powercap/intel-rapl:0/constraint_0_power_limit_uw
# 200000000

That's a 200 W PL1 on a mobile chip that draws tens of watts under load — effectively unlimited. RAPL wasn't holding anything back, and the only change I could make there (lowering it) would have hurt. Leave it alone.

The lever was the Dell ACPI platform_profile. On Dell hardware the firmware exposes a power/performance profile that gates real power delivery regardless of the RAPL number. The box had come up on a balanced profile, and that — not the governor, not RAPL — was the throttle. Setting it to performance, plus pinning every core's energy-performance-preference to performance, freed the clocks:

# /etc/systemd/system/cpu-performance.service
[Unit]
Description=Set CPU/firmware to performance profile for CI builds
After=multi-user.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/bash -c 'echo performance > /sys/firmware/acpi/platform_profile'
ExecStart=/bin/bash -c 'for f in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do echo performance > "$f"; done'
[Install]
WantedBy=multi-user.target

A oneshot with RemainAfterExit=yes is the right shape here: it writes sysfs once at boot and then systemctl status reads active rather than dead-after-exit. The reason it has to run from Linux at all is the genuinely annoying bit — the firmware profile does not reliably inherit a "BIOS ultra-performance" setting across an OS reimage, so even if you set it in BIOS, you pin it again from the OS to be sure.

Two footnotes. power-profiles-daemon and tuned are both inactive on this box; if either ever gets pulled in as a dependency it will happily reset platform_profile back to balanced, so the move is to disable it rather than fight the unit. And thermald stays installed: an i7-10875H crammed into a 17-inch chassis is ultimately heat-bound on sustained all-core builds, so realistic peak all-core lands well below the 5.10 GHz single-core max no matter what the profile says. Freeing the clocks doesn't repeal thermodynamics — it just stops the firmware from throttling before the silicon is even warm.


The 80 % Battery Cap

A laptop that lives permanently on AC has a battery sitting at 100 % charge, 24 hours a day, slowly cooking itself. Lithium cells age fastest at a full state of charge, and an always-plugged-in machine is the worst case for longevity. I don't need the battery — but I'd rather it not swell up in a couple of years, so I cap it.

The dell-laptop driver exposes working charge thresholds in sysfs:

# /etc/systemd/system/battery-charge-limit.service — write start THEN end
echo 75 > /sys/class/power_supply/BAT0/charge_control_start_threshold
echo 80 > /sys/class/power_supply/BAT0/charge_control_end_threshold

Stop charging at 80 %, don't resume until it falls to 75 %. The order matters — the driver wants start < end, so you write the start threshold before the end threshold — and the 75/80 gap avoids micro-cycling the cell at the top of its range.

Then comes the genuinely confusing part. In Dell's firmware conservation modes, the embedded controller masks the reported state-of-charge up to 100 % even though the pack is physically capped at 80 %. So psutil / glances / anything reading the OS battery sensor will cheerfully show 100 % while charging actually stopped twenty percent ago. The cap is real; the displayed number is cosmetic. The first time you see "100 %" after setting an 80 % cap, the instinct is "it didn't work" — it did. For an honest readout, set the BIOS charge profile to Standard (not "Primarily AC Use") and cap purely via these sysfs thresholds, which is exactly why Standard was on the BIOS checklist earlier.


Two Runners on One Box

Here's where the laptop earns the bld in its name. It runs two CI runners at once, because my fleet has two git forges:

  • A Gitea act_runner for the Gitea Actions side.
  • A GitLab runner (docker-compose, mirroring the old bld-01 config) for GitLab CI.

The interesting part is how each one does docker build, because they do it differently.

The Gitea act_runner is configured for Docker-out-of-Docker (DooD): the host's Docker daemon is injected into the job, so a Gitea workflow's docker build runs against the host's BuildKit and the host's registry cache. That's root-equivalent and strictly single-operator — fine for a homelab box only I touch, not something you'd hand to a team.

The GitLab runner uses the more conventional Docker-in-Docker (dind): each job gets its own ephemeral Docker daemon.

The consequence is that BuildKit has to be enabled in two separate daemons — the host daemon that serves Gitea jobs, and the dind daemon (via a bind-mounted config/daemon.json) that serves GitLab jobs. They're genuinely different processes; turning BuildKit on in one does nothing for the other. Both of them point their pulls at regcache.munroenet.com, a pull-through registry mirror, so a base image fetched for one pipeline is cached for the next regardless of which forge triggered it.

One operational gotcha worth internalizing: don't bounce act_runner while a job is running. A restart cancels the in-flight job with a context canceled error. Apply runner-config changes when the box is idle, not mid-pipeline.


Saving the NVMe

CI is brutal on storage. Every docker build writes and rewrites layers, and image-heavy pipelines churn gigabytes per run. The SN730 in this laptop is a perfectly good consumer NVMe — but consumer drives have finite write endurance, and a build server is a write amplifier. I'd rather not burn through the drive in a year.

The bulk of that write volume is the dind layer store — the /var/lib/docker inside the GitLab dind daemon, where docker build layer churn lands. So I put it on tmpfs:

# GitLab runner config — RAM-back the dind layer store
[runners.docker.services_tmpfs]
"/var/lib/docker" = "size=12g"

RAM-backing the dind store does two things at once: builds get faster (layer writes hit memory, not flash), and the consumer SSD's write endurance is spared the worst of the churn. The 12 GB is a ceiling, not a preallocation — a build that overflows it fails its own job with ENOSPC instead of eating the host's RAM out from under everything else.

Two things I learned the hard way here:

  • Do not tmpfs /builds. The Docker executor already mounts /builds as its managed build volume, so adding a [runners.docker.tmpfs] entry on the same path fails every job at prepare-environment with Duplicate mount point: /builds. I shipped that briefly, watched every pipeline die identically, and reverted it. /builds only holds the git clone plus artifacts — small for image-heavy CI — so it was never the mount that mattered for wear anyway.
  • Verify overlay2, not vfs. After enabling the tmpfs store, run a job and check docker info inside it reports Storage Driver: overlay2, not vfs. overlay2-on-tmpfs works on the 6.x kernel; a silent vfs fallback would be slow and space-hungry, and you'd want to back the change out.

There's a known gap I left in deliberately: the host's /var/lib/docker stays on the NVMe, so Gitea act_runner builds still write layers to disk (they use the host daemon, not a dind). Only the GitLab dind side is RAM-backed. Tmpfs-ing the entire host Docker root was tempting but wrong — it would volatilize persistent images on every reboot. So the host side gets a different treatment: a daily systemd timer runs docker system prune on objects older than seven days, followed by an fstrim. Churn cleanup instead of churn avoidance.

The net effect is the part of the I/O that's pure throwaway scratch lives in RAM, and the part that needs to persist gets garbage-collected on a schedule. The drive sees a fraction of the writes a naive setup would inflict.


The GPU Moonlights

The RTX 2060 isn't there for CI. CI is a CPU and RAM workload — compiling, packing layers, running tests. The GPU sits idle through all of it. So I gave it a second job: local LLM inference, served by Ollama right on the box.

The two workloads coexist cleanly precisely because they don't compete for the same resource:

Workload Uses Doesn't touch
CI builds CPU threads, RAM, NVMe the GPU
LLM inference GPU + 6 GB VRAM the build threads

The one shared resource is thermals — both ultimately dump heat into the same 17-inch chassis — so I don't expect a full 16-thread build and heavy inference to run flat-out simultaneously without the box throttling. For bursty homelab load, which is what this actually is, it's a non-issue.

The honest limit is VRAM. 6 GB means small models only: 7-8B-class models at 4-bit quantization, embedding models, or 3-4B coding models for fast autocomplete. This box does not replace the M1 Max Studio for 30B-class work, and pretending otherwise would just mean swapping to disk and crawling. What it's great at is CI-side inference — a pipeline that needs to summarize a diff, generate an embedding, or run a quick structured-extraction step can hit localhost:11434 and get an answer without leaving the machine or paying a per-token API bill.


How the Trial Turned Out

The trial stuck. As of the end of May 2026, prb-bld-01 quietly handles the bulk of CI for the fleet — it runs the GitLab and Gitea runners for all workloads, with headroom to spare. Not because it won a fight, but because a machine doing only builds will always have an easier time than three VMs sharing a host with a dozen other jobs. Give CI dedicated hardware and it stops contending for cycles; that was the whole hypothesis, and it held.

With the laptop carrying the load, I powered down the MCI bld pool. The three datacenter build VMs — bld-01, bld-02, bld-03 — got their runners paused and the VMs powered off. Let me be clear about what that isn't: it isn't a verdict that the VMs were bad. They were never given a host to themselves. It's just that I no longer need to carve three slices out of a busy PowerEdge for builds when an idle laptop can do the same work on dedicated silicon. So the VMs go to cold standby — not deleted, ready to spin back up the moment the laptop falls over or I need burst capacity.

The economics are the easy part. The hardware was free — I already owned it, and it was doing nothing. It draws a fraction of what three VMs' share of a colocated host costs to keep warm. And the builds feel faster, but the honest reason isn't "newer silicon beats old" — it's that the laptop runs builds with the whole machine to itself, while the VMs were always splitting attention with everything else on that host. Dedicated beats contended. That's the entire finding.


What's Next

A few threads left to pull:

  • Make the GPU a shared endpoint. Right now the local model is consumed only on-box by CI jobs over localhost. Adding Tailscale would let other apps in the fleet call it the way they call the M1 Studio — turning a CI-side convenience into shared infrastructure. That's the one change that would justify an overlay network on this box.
  • prb-bld-02. I have a second XPS reserved for exactly this role. The whole point of building prb-bld-01 from playbooks rather than by hand is that standing up a sibling is a re-run, not a rebuild. When the load justifies it, there's a second builder waiting.
  • GPU and thermal monitoring. A heat-bound box doing double duty deserves proper visibility into VRAM, GPU utilization, and chassis temps — I'd rather catch a thermal trend from a Prometheus alert than from a build that mysteriously got slow.

The meta-lesson is the one I keep relearning, and it's the same one from the Tesla P4 post: the hardware you already own, sitting idle, is almost always the highest-leverage thing in the room — and the cheapest way to prove it is to give it a trial. An old laptop on my desk was one reimage and a handful of systemd units away from quietly carrying the fleet's builds. It didn't have to beat anything. It just had to be the machine that wasn't already busy. The right time to put the idle hardware to work is now.