<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://kelvinwei.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://kelvinwei.com/" rel="alternate" type="text/html" /><updated>2026-08-29T14:20:51+00:00</updated><id>https://kelvinwei.com/feed.xml</id><title type="html">Kelvin Wei</title><subtitle>CS Honours student. Notes on systems, hardware automation, computer vision and self-hosting.</subtitle><entry><title type="html">Automating Hardware Deployment — Look Ma, No Hands!</title><link href="https://kelvinwei.com/2026/01/automating-hardware-deployment/" rel="alternate" type="text/html" title="Automating Hardware Deployment — Look Ma, No Hands!" /><published>2026-01-30T00:00:00+00:00</published><updated>2026-01-30T00:00:00+00:00</updated><id>https://kelvinwei.com/2026/01/automating-hardware-deployment</id><content type="html" xml:base="https://kelvinwei.com/2026/01/automating-hardware-deployment/"><![CDATA[<p>My project as an intern with Thinkst has been to develop a pipeline for deploying Canary images to Raspberry Pi devices for testing purposes. This blog aims to illuminate the details of the project.</p>

<p>At the very start of the internship, I was posed with the question “What’s so hard about hardware testing?” Essentially, the task is to translate manual hardware deployment and testing procedures into an automated pipeline. It is peculiar that certain tasks, easy for a human, are hard for a machine to accomplish. Similarly, the task of flashing a Canary image (manually very easy) becomes difficult to accomplish in an automated manner. Flashing is an important step in the hardware deployment process, and it poses unique challenges.</p>

<h2 id="background-the-core-problem">Background: The Core Problem</h2>

<p>Canary Hardware can be mimicked using Raspberry Pi 3B and 5’s, hence these devices are particularly of interest in fulfilling the role of a “Device under Test” (DUT). Canary images (disk images) are a byte-by-byte representation of an entire storage device. All the software needed for a Canary hardware device to function resides in such an image.</p>

<p>However, Canary images and the software that resides in them require testing during development. To flash a new image for testing, a “Device under Test” (DUT) can’t overwrite the storage device on which its current operating system is running. Proceeding to do so would crash the OS, and the flashing operation would fail. Raspberry Pis only have a single storage device, an SD card slot, from which their operating system runs. How do you manage to flash the singular storage device that a device is running from in an automated fashion? This is the core problem that makes it difficult to develop a pipeline for deploying Canary images to Raspberry Pi devices. Switching between two different OS versions requires two different storage devices. However, a Raspberry Pi only has one.</p>

<p>There are incentives for solving the problem. If the automated flashing process is achieved, then it can be integrated into the CI/CD pipeline. This allows hardware tests to be integrated with the development of nightly builds of Canary images. Naturally, this leads to Thinkst Engineers in spotting problems and bugs much earlier in the development process. Catching a bug earlier would prevent a domino effect of potential problems due to the bug. A stronger incentive is that Canary tests can take up to a full day. The automated flashing process can be modified and extended to run the full test suite in parallel, which reduces the overall time it takes to test canaries.</p>

<h2 id="planning-potential-candidate-solutions">Planning: Potential Candidate Solutions</h2>

<p>In helping me get started, I received a project brief which contained the result of all the planning (I believe a whole years worth) done by the company prior to my arrival at Thinkst. This greatly helped and accelerated the progress of the project. Taking the prior research into account, my approach in tackling this project is a mixture of planning and hand-on practice. The starting point is to generate a list of candidate solutions, which then can be iteratively prototyped and explored hands-on.</p>

<p>During my research, I encountered a variety of booting methods. USB booting, Network (PXE) booting and Dual booting – different OS’s on separate partitions. Another promising solution is to use SD Card Multiplexers, which can directly automate the manual transferring of SD cards during the traditional flashing process. However, the list is filtered down to PXE booting and SD Card Multiplexers. Dual booting isn’t supported by the firmware on Raspberry Pi devices – it can’t choose which partition to load the OS from. USB booting offers a simple deployment path. However, PXE booting extends upon this functionality and is more useful.</p>

<p>Both candidate solutions, Network (PXE) booting and SD Card Multiplexers, can deploy an image to an SD card, but they are accomplished in different ways. Network booting can flash an image over a network. While an SD Card Multiplexers flashes an image with a multiplexer device acting as an intermediary. This sounds all good. However, how can this be accomplished?</p>

<h2 id="sd-card-multiplexers">SD Card Multiplexers</h2>

<p>The idea behind SD Card Multiplexers is simple. It functions as a switch which connects the SD card to either the “Host” or “DUT”. An SD Card Multiplexer directly automates the manual SD card transfer between two devices. SD Card multiplexers typically have 3 I/O slots: (1) SD Card Reader Slot (Connects the SD Card to be multiplexed). (2) SD Card Head (Connects to the Device Under Test). (3) USB port (Connects to the Host)</p>

<figure>
  <img src="/assets/images/posts/automating-hardware-deployment/sd-card-multiplexers.jpeg" alt="Two SD card multiplexers side by side" />
  <figcaption>Two different SD card multiplexers are used. Black – USBSDMux Fast, Green – SDWire3.</figcaption>
</figure>

<p><strong>The flashing process is as follows:</strong></p>

<ol>
  <li>The Host receives a request to flash a new test image to a “Device Under Test” (DUT).</li>
  <li>The Host uses the multiplexer controller to switch the SD card connection to the “Host” side.</li>
  <li>The Host flashes the new Canary image to the SD Card</li>
  <li>The Host uses the multiplexer controller to switch the SD card connection back to the “DUT” side.</li>
  <li>The Host sends a signal to reboot the DUT</li>
  <li>The DUT now reboots into the newly flashed test image</li>
</ol>

<p>There is a problem that is encountered in this process. Similar to the core problem, as described before, the OS will crash if it is suddenly disconnected from its storage device. However, the OS crashing isn’t a train wreck, as it is possible to automatically hard reboot a Raspberry Pi via GPIO pins. Soldering is required to attach header pins to allow rebooting via the GPIO pins. Header pins need to be attached to the RUN headers on Raspberry Pi 3B, and similarly, attach header pins to J2 headers on a Raspberry Pi 5.</p>

<figure>
  <img src="/assets/images/posts/automating-hardware-deployment/raspberry-pi-3b-and-5.jpeg" alt="A Raspberry Pi 3B next to a Raspberry Pi 5" />
  <figcaption>Left: Raspberry Pi 3B. Right: Raspberry Pi 5</figcaption>
</figure>

<p>The final setup is shown in the image below. The full set-up adds another two Pi’s, however, it becomes very messy.</p>

<p><img src="/assets/images/posts/automating-hardware-deployment/sd-mux-bench-setup.jpeg" alt="The assembled SD card multiplexer test bench" /></p>

<h3 id="faulty-sdwire3-hardware-problems">Faulty SDWire3 Hardware Problems</h3>

<p>A performance metric of interest is the flashing/writing speed. For comparison, a Thunderbolt USB-C Hub flashes an SD Card at 62 MB/s – roughly 2m 20s spent flashing a Canary image.</p>

<p>Network booting is constrained by the speed of the respective Raspberry Pi’s SD card slot and its network interface. This is not a problem with the Pi 5, where it’s flashing speed is 23 MB/s and translates to roughly 6m 20s. It is a problem with the Pi 3B, as its flashing speed is 7.8 MB/s, and it’s ethernet interface’s speed is capped at 12.5 MB/s. This translates to roughly 20 mins spent on the flashing operation.</p>

<p>The potential advantage of SD Card Multiplexers is demonstrated by SDWire3. SDWire3 supports USB 3.0 speeds and can flash an SD card at 49MB/s – roughly 3 minutes. However, the hardware is faulty. When it uses USB 3.0, the switching functionality does not work and is also not compatible with SDR104 speeds used by Raspberry Pi 5. This results in USB 2.0 speeds being used as a fall back, and the speed decreases to 19.3MB/s – roughly 7m 57s.</p>

<h2 id="network-pxe-booting">Network (PXE) Booting</h2>

<p>PXE is the standard that allows a device to boot an operating system from the network. Hence, the whole process is called PXE or Network booting. What makes this technology powerful is how flexible the operating systems can be: Recovery OS, OS mounted on remote drives, OS installer and our OS of interest RAM-only OS.</p>

<p>Typical OS’s requires an external storage medium in conjunction to a device’s RAM. RAM-only OS’s are very interesting as they can independently reside in RAM once loaded. Additionally, they are key to solving our core problem. As the OS can run independently from RAM, flashing our singular storage device (SD card) can be done without the OS crashing. <a href="https://en.wikipedia.org/wiki/List_of_Linux_distributions_that_run_from_RAM">List of <em>RAM only OS’s</em></a></p>

<p>Typical OS’s requires an external storage medium in conjunction to a device’s RAM. RAM-only OS’s are very interesting as they can independently reside in RAM once loaded. Additionally, they are key to solving our core problem. As the OS can run independently from RAM, flashing our singular storage device (SD card) can be done without the OS crashing.</p>

<p>PXE booting facilitates loading a RAM-only OS to a PXE (DUT) Client’s RAM. This allows the DUT to boot into the RAM-only OS. The DUT can now download our Canary image into RAM and flash it to its local storage. What to do if a Canary image is too big for a device’s RAM? The downloading operation can be piped to the flashing operation, bypassing the need for temporarily storing the whole Canary image into RAM.</p>

<h3 id="details-loading-a-ram-only-os">Details: Loading a RAM-only OS</h3>

<p>A PXE Server consists of multiple servers working together to allow a PXE Client to network boot. The PXE Server consists of a DHCP Proxy Server, TFTP Server and HTTP Server.</p>

<p><strong>Typical PXE Flashing process:</strong></p>

<ul>
  <li>A PXE Client Device starts the network booting process by sending a broadcast request to the network’s DHCP server.</li>
  <li>The DHCP server would provide an IP address, and the DHCP Proxy would provide an IP address for the TFTP Server.</li>
  <li>The PXE Client would request the necessary files to boot into the kernel from the TFTP Server.</li>
  <li>Kernel is loaded and in control. The kernel requests additional OS files, including a custom overlay, from the HTTP Server</li>
  <li>Custom flashing script is run on start-up, flashes the local storage device (SD Card), and reboots into the newly flashed storage device.</li>
</ul>

<figure>
  <img src="/assets/images/posts/automating-hardware-deployment/pxe-flashing-pipeline.png" alt="Diagram of the PXE boot flashing pipeline" />
  <figcaption>Overview of PXE boot flashing pipeline</figcaption>
</figure>

<p>Alpine OS, for a RAM-only OS, was picked as it has a robust and active community. It is commonly used as a base for Docker images, and it’s very small at around 100MB. Additionally, to automate the flashing process, we need a custom overlay (<a href="https://wiki.alpinelinux.org/wiki/Diskless_Mode#Apkovl">apkovl</a>) file to run a startup script that flashes the Raspberry Pi’s SD Card.</p>

<p>Configuring the PXE Server to serve Alpine OS to PXE clients requires splitting up the OS, normally present in the boot partition, into the right locations. The files of interest are “config.txt” and “cmdline.txt”, which respectively describe hardware settings and kernel parameters. More information is detailed in the <a href="https://autocanaryhardwaretesting.pages.dev/">project’s documentation</a>.</p>

<h2 id="completing-the-pipeline-runner-it-down">Completing the Pipeline: Runner it down</h2>

<p>To handle dynamic flashing requests, a PXE (Network) Runner is created. The role of the runner is similar to Github Runner. They take input and execute jobs / tasks in a pipeline. The PXE runner exposes an API which allows dynamic specification of image and device to be flashed.</p>

<p>The runner helps complete the whole image deployment pipeline. It instructs a PXE-enabled device to network boot via SSH. This is requested via an HTTP POST Request.</p>

<p><img src="/assets/images/posts/automating-hardware-deployment/pxe-runner-boot-output.png" alt="Terminal output from the PXE runner triggering a network boot" /></p>

<p>If we look at our PXE server logs, it tells us that the specified PXE client device is now network booting and how the client interacts with different services.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pxe-dnsmasq  | dnsmasq-dhcp[1]: 4141337700 available DHCP subnet: 192.168.1.0/255.255.255.0
pxe-dnsmasq  | dnsmasq-dhcp[1]: 4141337700 vendor class: PXEClient:Arch:00000:UNDI:002001
pxe-dnsmasq  | dnsmasq-tftp[1]: file /pxe/tftpboot/c68672ee/config.txt not found <span class="k">for </span>192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: sent /pxe/tftpboot/config.txt to 192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: file /pxe/tftpboot/pieeprom.sig not found <span class="k">for </span>192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: sent /pxe/tftpboot/bcm2712-rpi-5-b.dtb to 192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: sent /pxe/tftpboot/config.txt to 192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: sent /pxe/tftpboot/config.txt to 192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: error 0 Early terminate received from 192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: sent /pxe/tftpboot/alpine/vmlinuz-rpi to 192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: sent /pxe/tftpboot/alpine/initramfs-rpi to 192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: sent /pxe/tftpboot/bcm2712-rpi-5-b.dtb to 192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: sent /pxe/tftpboot/alpine/overlays/overlay_map.dtb to 192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: sent /pxe/tftpboot/alpine/overlays/bcm2712d0.dtbo to 192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: sent /pxe/tftpboot/config.txt to 192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: sent /pxe/tftpboot/cmdline.txt to 192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: file /pxe/tftpboot/armstub8-2712.bin not found <span class="k">for </span>192.168.1.66
pxe-dnsmasq  | dnsmasq-tftp[1]: sent /pxe/tftpboot/alpine/vmlinuz-rpi to 192.168.1.66
pxe-dnsmasq  | dnsmasq-dhcp[1]: 1858184014 available DHCP subnet: 192.168.1.0/255.255.255.0
pxe-dnsmasq  | dnsmasq-dhcp[1]: 1858184014 vendor class: udhcp 1.37.0
pxe-dnsmasq  | dnsmasq-dhcp[1]: 1858184014 available DHCP subnet: 192.168.1.0/255.255.255.0
pxe-dnsmasq  | dnsmasq-dhcp[1]: 1858184014 vendor class: udhcp 1.37.0
pxe-http     | 192.168.1.13 192.168.1.52:5000 - <span class="o">[</span>29/Jan/2026:15:06:10 +0000] <span class="s2">"GET /alpineOS/netboot.apkovl.tar.gz HTTP/1.1"</span> 200 17305 <span class="s2">"-"</span> <span class="s2">"Wget"</span>
pxe-http     | 192.168.1.13 192.168.1.52:5000 - <span class="o">[</span>29/Jan/2026:15:06:12 +0000] <span class="s2">"GET /alpineOS/modloop-rpi HTTP/1.1"</span> 200 52731904 <span class="s2">"-"</span> <span class="s2">"Wget"</span>
pxe-dnsmasq  | dnsmasq-dhcp[1]: 3018030186 available DHCP subnet: 192.168.1.0/255.255.255.0
pxe-dnsmasq  | dnsmasq-dhcp[1]: 3018030186 vendor class: udhcp 1.37.0
pxe-dnsmasq  | dnsmasq-dhcp[1]: 3018030186 client provides name: netboot
pxe-dnsmasq  | dnsmasq-dhcp[1]: 3018030186 available DHCP subnet: 192.168.1.0/255.255.255.0
pxe-dnsmasq  | dnsmasq-dhcp[1]: 3018030186 vendor class: udhcp 1.37.0
pxe-dnsmasq  | dnsmasq-dhcp[1]: 3018030186 client provides name: netboot
pxe-runner   | <span class="o">[</span>API] Config request from MAC: 88:a2:9e:5a:c2:af, Node: 0009fb9bc68672ee
pxe-runner   | <span class="o">[</span>API] Status Change: 0009fb9bc68672ee <span class="o">(</span>flashing -&gt; FLASHING<span class="o">)</span>
pxe-runner   | INFO:     192.168.1.13:34298 - <span class="s2">"POST /flash/client/config HTTP/1.1"</span> 200 OK
pxe-http     | 192.168.1.13 192.168.1.52:5000 - <span class="o">[</span>29/Jan/2026:15:06:24 +0000] <span class="s2">"GET /imageRepo/canary-pi5.bmap HTTP/1.1"</span> 200 732086 <span class="s2">"-"</span> <span class="s2">"curl/8.17.0"</span>
pxe-http     | 192.168.1.13 192.168.1.52:5000 - <span class="o">[</span>29/Jan/2026:15:06:29 +0000] <span class="s2">"GET /imageRepo/canary-pi5-modded.img.xz HTTP/1.1"</span> 200 681294460 <span class="s2">"-"</span> <span class="s2">"curl/8.17.0"</span>
pxe-runner   | Received log upload from Node ID: 0009fb9bc68672ee with status code: 0
pxe-runner   | Successfully saved log: node_0009fb9bc68672ee_2026-01-29_15-08-03.log
</code></pre></div></div>

<p>After network booting and flashing its own local storage device. The runner also handles the discovery of device start-up after flashing via mDNS.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pxe-runner   | Status 0 received. Queuing mDNS scan...
pxe-runner   | INFO:     192.168.1.13:44818 - <span class="s2">"POST /flash/client/upload/log HTTP/1.1"</span> 200 OK
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Starting mDNS scan <span class="k">for </span>120 seconds...
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Looking <span class="k">for </span>MAC: 88:a2:9e:5a:c2:af excluding IP 1.1.1.1
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Listening <span class="k">for </span>devices matching: <span class="o">[</span><span class="s1">'srv01'</span><span class="o">]</span>...
pxe-runner   | <span class="o">[</span>+] Service added: pxe-server <span class="o">[</span>88:a2:9e:5a:c1:9d]._workstation._tcp.local.
pxe-runner   | <span class="o">[</span>+] Service added: pxe-server <span class="o">[</span>00:00:00:00:00:00]._workstation._tcp.local.
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Update event received <span class="k">for </span>pxe-server <span class="o">[</span>88:a2:9e:5a:c1:9d]._workstation._tcp.local.
pxe-runner   | <span class="o">[</span>+] Service added: pxe-server <span class="o">[</span>c2:87:83:58:67:dc]._workstation._tcp.local.
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Update event received <span class="k">for </span>pxe-server <span class="o">[</span>88:a2:9e:5a:c1:9d]._workstation._tcp.local.
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Update event received <span class="k">for </span>pxe-server <span class="o">[</span>00:00:00:00:00:00]._workstation._tcp.local.
pxe-runner   | <span class="o">[</span>+] Service added: SRV01 <span class="o">[</span>88:a2:9e:5a:c2:af]._workstation._tcp.local.
pxe-dnsmasq  | dnsmasq-dhcp[1]: 585076302 available DHCP subnet: 192.168.1.0/255.255.255.0
pxe-dnsmasq  | dnsmasq-dhcp[1]: 585076302 client provides name: SRV01
pxe-dnsmasq  | dnsmasq-dhcp[1]: 585076302 available DHCP subnet: 192.168.1.0/255.255.255.0
pxe-dnsmasq  | dnsmasq-dhcp[1]: 585076302 client provides name: SRV01
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Ignored SRV01.local: Only has 1.1.1.1 or no IP.
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Update event received <span class="k">for </span>SRV01 <span class="o">[</span>88:a2:9e:5a:c2:af]._workstation._tcp.local.
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Ignored SRV01.local: Only has 1.1.1.1 or no IP.
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Update event received <span class="k">for </span>SRV01 <span class="o">[</span>88:a2:9e:5a:c2:af]._workstation._tcp.local.
pxe-runner   | <span class="o">[</span>SUCCESS] Target Found: SRV01.local | IP: 192.168.1.13 | MAC: 88:a2:9e:5a:c2:af
pxe-runner   | Stopping scan, valid device found.
pxe-runner   | 
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> mDNS scan finished/closed.
</code></pre></div></div>

<p>Finally, it handles linking the newly flashed device to a Canary Console. Now being linked to a console, testing can occur. However, that’s currently out of the scope of this project.</p>

<p><img src="/assets/images/posts/automating-hardware-deployment/device-linked-to-console.png" alt="A freshly flashed device linked to a Canary Console" /></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Triggering config check on 192.168.1.13...
pxe-runner   | Using MAC Suffix: 5a%3Ac2%3Aaf
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Step 1: Pushing configuration to <span class="nb">local </span>device at 192.168.1.13...
pxe-runner   | <span class="o">[</span>+] Success: Received registration blob <span class="o">(</span>11928 bytes<span class="o">)</span><span class="nb">.</span>
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Step 2: Authenticating with Canary Console <span class="o">(</span>https://338edead.canary.tools<span class="o">)</span>...
pxe-runner   | <span class="o">{</span>
pxe-runner   |   <span class="s2">"redirect_to"</span>: <span class="s2">"/"</span>, 
pxe-runner   |   <span class="s2">"result"</span>: <span class="s2">"success"</span>
pxe-runner   | <span class="o">}</span>
pxe-runner   | 
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Step 3: Acquiring CSRF tokens <span class="k">for </span>API operations...
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Step 4: Registering the device hardware with the Console...
pxe-runner   | <span class="o">[</span>+] Success: Device registered with Node ID: 0009fb9bc68672ee
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Step 5: Commissioning device into Flock: flock:7ac622e57afe21a1e2274c9d694dcc56...
pxe-runner   | <span class="o">[</span>+] Success: Device commissioned and assigned to flock.
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Step 6: Finalizing handshake with <span class="nb">local </span>device...
pxe-runner   | <span class="o">[</span><span class="k">*</span><span class="o">]</span> Command Sent: Rebooting device to apply changes...
pxe-runner   | 
pxe-runner   | <span class="o">[!]</span> Setup Complete. The device should appear online shortly.
</code></pre></div></div>

<h2 id="pikvm-remote-access">PiKVM: Remote Access</h2>

<p>PiKVM can provide remote access to a connected device over the network. This is useful for remote debugging of a DUT. They also expose a REST API, so you can programmatically send actions for the PiKVM to perform. It can also perform OCR and attach a virtual mass storage device.</p>

<p>The <a href="https://docs.pikvm.org/v2/">PiKVM</a> runs on a Raspberry Pi 4 and uses an <a href="https://www.pishop.co.za/store/pi-kvm-v2-hdmi-to-csi-adapter-with-standard-csi-ffc">HDMI to CSI adapter</a> and a USB Cable to capture video input and emulate mouse and keyboard signals. If the hardware is all connected correctly, then it is a very painless setup process. Simply flash the correct PiKVM image to an SD card and boot from the Pi 4. The PiKVM runs on top of Arch Linux, and it automatically configures and exposes the PiKVM at its IP address. By default, the PiKVM’s memory is mounted as read-only, which prevents accidental changes.</p>

<figure>
  <img src="/assets/images/posts/automating-hardware-deployment/pikvm-setup.jpeg" alt="The PiKVM rig and its captured video output" />
  <figcaption>Left: PiKVM + connected device. Right: Captured Input from PiKVM interface</figcaption>
</figure>

<p>The PiKVM’s REST API can be utilized to instruct a DUT to network boot without SSH – The PXE Runner uses SSH to accomplish this. This is useful as production Canaries do not have SSH enabled. Feedback on if command executed successfully can be obtain via OCR or utilizing keyboard LED states by sending and reading custom LED states. In fact, if a screen is not needed, then a Raspberry Pi Pico W can instead be used.</p>

<h2 id="reflection">Reflection</h2>

<p>Reflecting back, the internship was really fun and an invaluable time. Adjusting to a new environment is always hard at first. Yet, much support was given. Louis, my mentor, graciously flew down from Pretoria to the Cape Town Thinkst office during my internship for a productive two weeks. If hardware was missing, then my mentor would promptly have it ordered.</p>

<p>Admittedly, I did not ask enough questions about problems I encountered, but the few questions that I did ask were met with unexpected enthusiasm and attention. It was at these moments that work felt valued and motivating.</p>

<p>There were also a lot of small side quests, which I learnt a lot from and I didn’t get to talk about them in this blog. Learning to solder was very fun. 3D printing takes long time, but nevertheless an exciting activity.</p>

<p><img src="/assets/images/posts/automating-hardware-deployment/soldering-and-3d-printing.jpeg" alt="Soldering and 3D printing side quests" /></p>

<p>For the bad… I clearly don’t have the best luck with hardware. I somehow bricked a Raspberry Pi 3B and some GPIO pins on the PXE Server while experimenting with rebooting Pi’s with GPIO pins. I can be thankful that nothing was short-circuiting, giving off smoke and becoming a firehazard – I prefer not to become known as the intern who burnt down the Thinkst office.</p>

<figure>
  <img src="/assets/images/posts/automating-hardware-deployment/bricked-raspberry-pi-3b.jpg" alt="A bricked Raspberry Pi 3B" />
  <figcaption>Bricked Raspberry Pi 3B</figcaption>
</figure>

<p>Also staying in the office for too long past the intended 5pm clearly isn’t sustainable. However, when you’re having fun, the hours do seem to fly by too fast.</p>

<h2 id="conclusion">Conclusion</h2>

<p>What’s so hard about hardware testing? For this project, the difficulty doesn’t lie in the software itself, but in the constraints that hardware imposes. Hardware can’t be changed as easily as software. The skills to work with hardware are different from software, yet they are fundamentally related.</p>

<p>This project has led to unexpected destinations. For context, I had minimal experience with hardware before this internship. However, I dove headfirst into the unfamiliar territory, and I’m glad that I did. They say getting started is the most difficult part. Hence, I believe I’ll work on hardware projects in the future.</p>]]></content><author><name></name></author><category term="hardware" /><category term="pxe" /><category term="automation" /><category term="internship" /><summary type="html"><![CDATA[Building a pipeline that flashes and boots Canary images onto Raspberry Pi hardware with nobody in the room.]]></summary></entry><entry><title type="html">Explorating the World of Self-Hosting</title><link href="https://kelvinwei.com/2025/06/exploration-self-hosting/" rel="alternate" type="text/html" title="Explorating the World of Self-Hosting" /><published>2025-06-23T00:00:00+00:00</published><updated>2025-06-23T00:00:00+00:00</updated><id>https://kelvinwei.com/2025/06/exploration-self-hosting</id><content type="html" xml:base="https://kelvinwei.com/2025/06/exploration-self-hosting/"><![CDATA[<p>This journey started from a simple realisation. The foundation of web applications that we use on a daily basis is the hardware. Cloud services provide abstracted features that seem magical. However, a simple VPS provides all the necessary tools required to deploy an application. Provided that you are willing to handle all the underlying symphony of components which are usually hidden, there is a rich open-source self-hosting community and ecosystem to support deploying an application suited for your own needs.</p>

<p>Personally, I had a desire for a cheap and reliable way to manage my finances. Initially, I stored transactions in an Excel spreadsheet — surprisingly functional despite all its flaws. I explored around the self-hosting community and found Firefly III, an open-source,
self-hosted solution for managing your finances. Alas, the self-hosting rabbit hole started here.</p>

<p>Initial setup was easy by following the documentation. However, the lack of features that are normally taken for granted becomes painfully clear. Lack of HTTPS, service accessed manually through IP address and port number, lack of monitoring capabilities. It was also surprising to discover the frequency with which bots scan the internet to exploit vulnerabilities. Hence, secure access over the internet is required. Solving all these issues required many hours of reading documentation, trial and error, and motivation to come back to fix a seemingly never-ending abyss of issues.</p>

<p>Iteration through trial and error was a huge component of this journey. Looking back, my naivety in the past is embarrassing, to say the least. Yet, I suppose some naive optimism is what’s needed to get started. Even now, I am still iterating to improve upon decisions made naively in the past.</p>

<h2 id="the-set-up">The set-up</h2>

<p>Below is a conceptual overview of the whole set-up. The operation runs on a cheap but generous VPS from Hetzner, at $5.28 per month.</p>

<p><img src="/assets/images/posts/self-hosting/diagram-overview.jpg" alt="Conceptual overview of the self-hosted stack" /></p>

<p>Ideally, self-hosting would be done with my own hardware. However, I found a VPS to suit my personal circumstances best. I initially started self-hosting by turning my old laptop into a Linux server, where you can use Tailscale to solve residential NAT and IP issues. However, load shedding and attending university in another city are major influencing factors in using a VPS.</p>

<h2 id="technologies">Technologies</h2>

<h3 id="docker">Docker</h3>

<p>Docker plays a huge part in making it easy to deploy multiple application services and their configurations. All services are deployed and managed with Docker. An added benefit of containerisation is that most services can be horizontally scaled without much extra work.</p>

<h3 id="reverse-proxy">Reverse proxy</h3>

<p>Traefik is the reverse proxy in front of the applications on the server. It has very useful integrations with Docker and supports authentication middleware for hardening access.</p>

<h3 id="authentication">Authentication</h3>

<p>Traefik uses Authelia as a middleware to provide authentication and 2FA. It can also integrate with LDAP for single sign-on and a centralised credential store.</p>

<h3 id="vpn">VPN</h3>

<p>WireGuard is the tunnel used to reach the reverse proxy, and hence the application services. The firewall on the server is configured so that only requests arriving over the VPN are let through.</p>

<p>Security is a crucial consideration here, taken to the best of my current abilities, because of privacy and the management of confidential financial data. Resources are constrained, but it doesn’t mean that security should be neglected. Additionally, Canary honeypots are littered throughout the server as a safety measure to detect attacks that get past the measures already in place.</p>]]></content><author><name></name></author><category term="self-hosting" /><category term="docker" /><category term="infrastructure" /><summary type="html"><![CDATA[A journey into what's possible with self-hosting.]]></summary></entry></feed>