3D printed 10" homelab rack with ESP32 fan control for Strix Halo cooling

August 4, 2026 - Rohan van der Walt

Problem

A 2-bay NAS, a network switch and a GMKtec EVO-X2 sitting around my office with a spaghetti of cables. There has to be a better way.

The real problem was heat. I bought the EVO-X2 for local inference, an AMD Ryzen AI Max+ 395 "Strix Halo" with 128GB of unified LPDDR5X, running llama-swap in front of llama.cpp so I can hot-swap models. Under sustained inference it sat at 98C, right at the throttle point.

The finished 3D printed Lab Rax 10 inch rack with the GMKtec EVO-X2 and NAS mounted
The finished stack. Top fan panel, EVO-X2 (stripped), fan shelf, NAS.

The rack: Lab Rax

I printed Lab Rax, a modular 10" rack system by Michael Klements. There is an original version using brass heat-set inserts and a bolted version needing only M6 button heads and nuts - I went bolted, so it comes apart cleanly when I re-shelf something. The full collection has shelves, blanking plates and fan holders, and The DIY Life has a good write-up.

Check your filament before you start. PLA is all I had initially, but PLA creeps and softens well below the temperatures this rack sees with a hot mini PC bolted into it. That sent me on a shopping trip for PETG and a hardened steel nozzle.

Printed on a Bambu P1S. (256mm bed limit)

Custom parts, designed by an LLM

The frame is a download, but my components are not standard rack gear. Three custom parts:

  1. Top fan panel. The bundled Lab Rax panel is drilled for a 120mm fan; I wanted a 140mm Noctua. Same 228 x 171.5 x 3mm footprint, one round 133mm cutout (140mm frame leaves a ~3.5mm seating lip), four 4.4mm holes on the standard 124.5mm square.
  2. 1U fan mount. Open-frame bracket holding a 140mm fan flat, so it moves air vertically through the rack. Ears bolt to the front rails, wings carry the fan centred in the case depth (110mm into 220mm).
  3. Enclosure for the electronics - vented screw-down box with M3 corner bosses.

Yes, you can get an LLM to generate CAD parts.

What made it work:

  • Give it real measurements. "A bracket to hold a fan in a rack" gets you nothing. "Rail holes 236.525mm apart, two M6 per side at EIA 1U positions 6.35 and 38.1mm up from the U boundary" gets you a part.
  • Use the correct vocabulary. Collar, boss, gusset, fillet, pilot hole, seating lip.
  • Always open the result and measure it. I got features in the wrong plane and gussets sitting on top of bolt holes. Open the STEP in Fusion 360 (a clean editable solid, not a mesh) and use the measuring tool on it before committing print time.

That was 80% of the work done. The last 20% was measurement checks and hand edits.

The custom printed 1U fan mount holding a 140mm Noctua fan flat in the rack
The printed 1U fan mount, holding the lower Noctua flat under the EVO-X2.

Stripping the EVO-X2

The EVO-X2 came out of its case entirely. Inside a closed rack it does not need a second shell, and that shell was what trapped the heat - the SoC was reheating its own intake air.

The GMKtec EVO-X2 mounted in the rack with its outer case removed, internal fan and board visible
EVO-X2 with the body off. Front IO still reachable through the printed faceplate.

The fans

Two Noctua NF-A14x25 G2 PWM chromax.black 140mm fans - one in the top panel, one on the shelf below the EVO-X2, giving a continuous vertical column of air.

These are superb. PWM controllable from 1500rpm all the way down, and at low speed they are completely silent.

The catch: a 4-pin fan on a bare 12V line just runs at whatever its default PWM state gives you. I had no way to control them.

The controller

An ESP32 reads rack air temperature from a DS18B20 and drives both fans on a curve.

A DALLAS DS18B20 temperature sensor on a KY-001 breakout board
DS18B20 on a KY-001 breakout. 1-Wire, 4.7k pull-up already on the board.

Design decisions

  • Continuous proportional curve, not on/off. No hysteresis needed, because there is no boundary to oscillate around.
  • No power switching. No MOSFET, no relay. The 12V line stays permanently on and only the PWM signal is modulated - at 0% duty the fans stop anyway. One less part, one less failure mode.
  • ESP32 over an ATtiny or a thermistor-and-op-amp. Reboots independently of the server, LEDC gives 25kHz PWM for free, and WiFi telemetry stays a software change instead of a hardware change. I had three spare.
  • No WiFi in v1. Fewest failure modes. Radio and fan tach pin left free for later.
  • One 12V brick. Feeds the fans directly; an LM2596 buck steps it to 5V for the ESP32. One cable into the box.
duty
100% |                         _________
     |                     ___/
     |                 ___/
 ~0% |________________/
     +-------|--------|--------|--------
            30C      40C      50C

Firmware

The core is a pure function: temperature in, 8-bit duty out. No Arduino dependencies, so it compiles both in the sketch and in a plain g++ host build.

// fan_curve.h - pure temperature -> PWM-duty mapping.
static const float FAN_TEMP_MIN_C = 30.0f;  // at/below this: fans idle (0% PWM)
static const float FAN_TEMP_MAX_C = 50.0f;  // at/above this: 100% PWM

// Map a temperature (deg C) to an 8-bit PWM duty (0..255).
// Invalid readings -> 255 (fail-safe full speed).
static inline uint8_t tempToDuty(float tempC) {
  if (isnan(tempC) || tempC < -40.0f || tempC > 125.0f) {
    return 255;  // fail-safe: sensor fault / disconnected (DS18B20 = -127)
  }
  if (tempC <= FAN_TEMP_MIN_C) return 0;
  if (tempC >= FAN_TEMP_MAX_C) return 255;
  float frac = (tempC - FAN_TEMP_MIN_C) / (FAN_TEMP_MAX_C - FAN_TEMP_MIN_C);
  long duty = lround(frac * 255.0f);
  if (duty < 0) duty = 0;
  if (duty > 255) duty = 255;
  return (uint8_t)duty;
}

Any invalid reading returns 255. setup() also writes 255 before the first read, so a boot loop never leaves the rack unventilated.

Testing

Simulation. Wokwi has an ESP32 and a DS18B20 slider but no 4-pin fan, so two LEDs on GPIO25 stand in as duty meters: dark below 30C, half at 40C, full at 50C, full when you pull the sensor. Great when it worked, but getting a slot to compile on the free tier was a gamble.

Bench. Then the real thing, on a current-limited PSU at ~0.8A, this gave me some peace of mind with over-current-protection.

Bench test setup with the ESP32 protoboard, LM2596 buck converter and a fanHolding the DS18B20 sensor to force the fans to spin up during bench testing
Bench bring-up. LM2596 display reading 5.0 is the ESP32 supply. Right: warming the sensor by hand to force the ramp.

Holding the sensor between two fingers is enough to activate the >30C duty. Then pull the lead and confirm the fans jump to 100%.

The permanent build

Closeup of the soldered protoboard with a socketed ESP32-WROOM-32D module
The soldered board. ESP32 on female headers, not soldered down.

Built in two phases: core board with no capacitors, tested end to end, then the decoupling caps (100uF across 12V for fan inrush, 22uF across 5V at the ESP32). Polarity-sensitive parts go onto a known-good board.

First real soldering project of any size, and a socketed ESP32 plus headers is great practice.

The lower Noctua fan mounted in the rack with the printed controller enclosure beside it
Installed. Fan on the printed shelf, controller box on the left, DS18B20 up at the EVO-X2 exhaust.

The DS18B20 sits at the warm side of the EVO-X2, measuring the exhaust air.

Results

The Lab Rax works great to combine the loose components in my office.

98C down to around 88C under sustained inference load.

Just as important, it is quiet. Below 30C the fans are stopped, and even once they are running at low duty I cannot hear them at my desk. They only ramp up while a model is generating.

Parts

PartRole
ESP32-WROOM-32DLEDC 25kHz PWM + 1-Wire
DS18B20 (KY-001 module)Rack air temperature, onboard 4.7k pull-up
2x Noctua NF-A14x25 G2 PWM chromax.black140mm, ~0.14A @ 12V each
LM2596 buck converter12V to 5V for the ESP32
12V/2A barrel PSU, 5.5x2.1mm centre-positiveSingle supply for fans + buck
5.5x2.1mm female barrel jack to screw terminal12V entry point, no soldering
5x7cm double-sided protoboardThe permanent build
M6 button head screws + nutsLab Rax bolted version
PETG filament + hardened steel nozzleRack, mounts, enclosure

Keywords

#Homelab #10InchRack #LabRax #3DPrinting #PETG #ESP32 #DS18B20 #Noctua #PWM #StrixHalo #GMKtec #LocalLLM #llamacpp #CadQuery #Fusion360 #BambuLab

×