Driving a Servo on a Raspberry Pi Zero W from Anywhere: Headless Setup, Hardware PWM, and Publishing with WireCanal
Connecting a servo to a 2017 Raspberry Pi Zero W, setting it up headless from a Windows PC alone, and moving it over the internet with one curl command. Software PWM jitter is fixed with hardware PWM, switchable via an HTTP API, and published on WireCanal's free plan with no open ports.
Hi, this is Michele!
I have an original Raspberry Pi Zero W on my desk.
A single-core ARMv6, 512 MB of RAM, and 2.4 GHz Wi-Fi only. It is a tiny board released in 2017, and next to today's Raspberry Pi 5 it looks like it belongs to a different generation.
I connected a single servo motor to this little board, set it up headless using nothing but a Windows PC, and got as far as moving the servo over the internet with a single curl command.
To be fair, this was not a solo effort. I got there with a great deal of support from our engineers.
The short version: I made it all the way from writing the SD card to moving the servo over the internet, with no monitor, no keyboard, and no port forwarding.
Along the way I ran into one interesting problem. When the servo was driven by software PWM, it kept twitching with a fine tremor. The timing jitter of software-generated PWM appeared to be the cause, and switching to the SoC's built-in hardware PWM stopped it completely. In this article, the "jittery PWM" and the "steady PWM" can be switched through an HTTP API, so you can check the difference over the internet with curl or from your phone.
On this blog, we have covered how to use small Linux boards from outside your home in the three-part series that starts with setting up a Raspberry Pi 5 without a monitor and in the article on using a $25 Luckfox Pico M from anywhere. This time, in the same spirit, I bring the oldest and least powerful board of the bunch back into service. Every number in this article was measured on my own setup (Zero W Rev 1.1, Raspberry Pi OS Lite 32-bit) on September 2 and 3, 2026. Timings will vary with your microSD card and network environment.
What you need
- Raspberry Pi Zero W (original, Rev 1.1). The pin header is sold separately, so instead of populating all 40 pins I soldered two 3-pin headers only around the pins I use. With the current wiring using an external power supply, the only pins in use are 30 (GND) and 32 (GPIO12)

- A microSD card. I used 64 GB (the filesystem is expanded to the full capacity automatically on first boot)
- A micro USB power supply. It goes into the "PWR IN" port at the edge of the board
- One small hobby servo (an SG92R in my case) and jumper wires
- An external power supply for the servo: a 5 V switching supply rated 2 A or more, plus a 100 µF electrolytic capacitor to place in parallel between the supply's +5 V and GND (rated above 5 V; 10 V or more gives a comfortable margin, and the one I used was rated 50 V) and a 0.1 µF ceramic capacitor
- A Windows 11 PC and a USB card reader
- Git for Windows. Its bundled openssl is used to create the password hash
No monitor and no HDMI cable. Everything is done from PowerShell and SSH on Windows.
How the Zero W differs from the Raspberry Pi 5: five things to know first
The steps themselves are almost the same as for the Raspberry Pi 5, but if you proceed without knowing the Zero W's quirks, the board may not boot from the SD card or may never join Wi-Fi. Here are the differences up front.
| Item | Raspberry Pi 5 | Raspberry Pi Zero W |
|---|---|---|
| OS image | 64-bit desktop edition (raspios_arm64_latest) | 32-bit edition required (it is ARMv6, so the 64-bit Raspberry Pi OS does not support it). Since this is a headless setup on 512 MB of RAM, I chose the 32-bit Lite edition (raspios_lite_armhf_latest) |
| Wi-Fi | 2.4 GHz / 5 GHz | 2.4 GHz only. It will never connect to a 5 GHz-only SSID |
| Power | USB-C | The micro USB port labeled "PWR IN" (at the board edge). The inner USB port is for OTG |
| First boot | About 3 minutes | About 5 minutes (initial setup and filesystem expansion are slow on a single core) |
| Hostname | raspberrypi | Set to pizero, because raspberrypi.local would collide with a Raspberry Pi 5 on the same LAN |
The verified environment: Raspberry Pi Imager 2.0.8 as the imaging tool, Raspberry Pi OS Lite 32-bit (based on Debian 13 trixie, kernel 6.18) as the OS, and a Windows 11 Pro PC with Git for Windows installed.
Step 1: Get the sample code and download the 32-bit Lite OS image
All the scripts and code used in this article are in a GitHub repository. First, fetch it on the Windows side. The rest of the steps assume this folder is the current directory.
mkdir C:\qualiteg_examples -Force
cd C:\qualiteg_examples
git clone https://github.com/qualiteg/wirecanal-iot-demo-raspberry-pi-zero-w-servo.git
cd wirecanal-iot-demo-raspberry-pi-zero-w-servoIf Raspberry Pi Imager is not installed yet, install it with winget, exactly as in the previous Raspberry Pi 5 article.
winget install --id RaspberryPiFoundation.RaspberryPiImager --silent --accept-package-agreements --accept-source-agreementsDownload the OS image from the official download site. The URL for the Zero W is raspios_lite_armhf_latest. It differs by a single character from the raspios_arm64_latest used for the Raspberry Pi 5, and getting this wrong produces an SD card that will not boot.
Put the OS image in the same setup folder as the imaging script (the script writes the image found in its own folder).
curl.exe -L --fail -o setup\raspios-lite-armhf.img.xz https://downloads.raspberrypi.com/raspios_lite_armhf_latestThe actual file was 2026-06-18-raspios-trixie-armhf-lite.img.xz, 524 MB compressed and 2,552 MB expanded. The download took about a minute. The xz-compressed file can be written as is, so there is no need to extract it yourself.
Step 2: Edit one configuration file and write the SD card
In the Raspberry Pi 5 article, I wrote firstrun.sh by hand and assembled the Imager command manually. This time the same work is split into two PowerShell scripts: setup/pizero-config.ps1 holds only the settings, and setup/flash-pizero.ps1 performs the write. You only edit the settings script. Because an English edition of this article was planned, all comments in the repository code are written in English.
setup/pizero-config.ps1(Full file. Replace the SSID and password with your own)
# ===== Raspberry Pi Zero W headless setup: settings =====
# Edit this file, then run flash-pizero.ps1.
# NOTE: the Zero W has 2.4 GHz Wi-Fi only. A 5 GHz-only SSID will never connect.
$WifiSsid = 'YOUR_SSID'
$WifiPassword = 'YOUR_WIFI_PASSWORD'
$WifiCountry = 'JP'
$Hostname = 'pizero' # a name that does not collide with raspberrypi.local
$UserName = 'pi'
$UserPassword = 'raspberry' # change it with `passwd` right after the first login
$Timezone = 'Asia/Tokyo'
$Keymap = 'jp'
$ImageFile = 'raspios-lite-armhf.img.xz' # the Zero W needs the 32-bit (armhf) imageThe imaging script does four things: a safety check of the target disk, hashing the password, generating firstrun.sh, and launching the Imager CLI. The values are shell-quoted before being embedded so that a single quote in the Wi-Fi password does not break firstrun.sh. Here are the key parts.
setup/flash-pizero.ps1(Excerpt of the key parts. See the GitHub repository for the full file)
# Quote a value for a POSIX shell single-quoted string ( ' becomes '\'' )
function Q([string]$v) { "'" + $v.Replace("'", "'\''") + "'" }
# --- 1. Confirm the target disk (NVMe, system/boot disks and anything over 256 GB are refused) ---
$disk = Get-Disk -Number $DiskNumber
if ($disk.BusType -eq 'NVMe' -or $disk.IsSystem -or $disk.IsBoot) {
throw "Disk $DiskNumber ($($disk.FriendlyName)) is a system disk. Aborting."
}
# --- 2. Hash the password (openssl bundled with Git for Windows) ---
$hash = (& $openssl passwd -6 $UserPassword).Trim()
# --- 3. Generate firstrun.sh (LF line endings, no BOM; CRLF breaks the first boot) ---
$firstrun = $firstrun.Replace("`r`n", "`n")
[IO.File]::WriteAllText("$PSScriptRoot\firstrun.sh", $firstrun, (New-Object System.Text.UTF8Encoding($false)))
# --- 4. Write with Imager CLI, elevated (physical disk = \\.\PhysicalDriveN) ---
$imgArgs = @('--cli', '--debug', '--disable-eject',
'--first-run-script', "`"$PSScriptRoot\firstrun.sh`"",
'--log-file', "`"$logFile`"",
"`"$PSScriptRoot\$ImageFile`"", "\\.\PhysicalDrive$DiskNumber")
Start-Process -FilePath $imager -ArgumentList $imgArgs -Verb RunAs -WaitThe generated firstrun.sh is short and simply calls the helpers bundled with Raspberry Pi OS. It sets the hostname, SSH, user, Wi-Fi, keyboard layout, and time zone in one go, then deletes itself and returns to a normal boot.
firstrun.sh(What the script generates, in full)
#!/bin/bash
set +e
/usr/lib/raspberrypi-sys-mods/imager_custom set_hostname 'pizero'
/usr/lib/raspberrypi-sys-mods/imager_custom enable_ssh
/usr/lib/userconf-pi/userconf 'pi' '$6$…(hash generated with openssl passwd -6)'
/usr/lib/raspberrypi-sys-mods/imager_custom set_wlan 'YOUR_SSID' 'YOUR_WIFI_PASSWORD' 'JP'
/usr/lib/raspberrypi-sys-mods/imager_custom set_keymap 'jp'
/usr/lib/raspberrypi-sys-mods/imager_custom set_timezone 'Asia/Tokyo'
rm -f /boot/firstrun.sh /boot/firmware/firstrun.sh
sed -i 's| systemd.run.*||g' /boot/cmdline.txt /boot/firmware/cmdline.txt 2>/dev/null
exit 0Insert the SD card into the card reader, check the disk number, and run the script from the setup folder.
cd setup
Get-Disk
# Number FriendlyName BusType SizeGB
# ------ ------------ ------- ------
# 0 CT4000P3PSSD8 NVMe 3726
# 1 Generic- SD/MMC/MS PRO USB 59.5
.\flash-pizero.ps1 -DiskNumber 1Allow the UAC prompt when it appears. Writing took 107 seconds and read-back verification took 67 seconds, 184 seconds in total. If the log ends with succeeded, the write succeeded.
[DEBUG] Write done in 107 seconds
[DEBUG] Verify hash: "235aae6e32f40eb294b6485f99232d9ea5b6ee0251c8dc40e370177fac4754c2"
[DEBUG] Verify done in 66.945 seconds
[DEBUG] writeFile: updateDirEntry succeeded for "firstrun.sh"
[DEBUG] writeFile: updateDirEntry succeeded for "cmdline.txt"
[DEBUG] PerformanceStats: Cycle ended, state: "succeeded"Here is a trap I actually fell into. My first attempt ended right after the "Drive added" line, finishing in 4 seconds without any error. The cause was that the destination was written as \.\PhysicalDrive1, missing one backslash. Imager exits with "Destination drive is not in list of removable volumes", but that message goes only to standard error, and because Imager is built as a GUI application it shows up neither on the console nor in --log-file. Write the destination as \\.\PhysicalDriveN, with two backslashes.
Step 3: Power on, wait 5 minutes, and connect over SSH
Insert the SD card into the Zero W and connect power to the micro USB port labeled "PWR IN" at the board edge. While the green LED blinks irregularly, the board is still booting. On first boot it expands the filesystem, runs firstrun.sh, and reboots automatically, so it takes longer than a Raspberry Pi 5: about 5 minutes.
Here is the measured timeline. I powered on at 18:45, the Raspberry Pi's address appeared on the LAN at 18:50:07, and I could log in over SSH at 18:50:39.
ssh pi@pizero.local
I also checked the state after logging in. Here are excerpts from the output of uname -a, vcgencmd measure_temp, free -m, and df -h.
Linux pizero 6.18.34+rpt-rpi-v6 #1 Raspbian 1:6.18.34-1+rpt1 (2026-06-09) armv6l GNU/Linux
temp=40.6'C
total used free shared buff/cache available
Mem: 426Mi 116Mi 244Mi 2.4Mi 116Mi 310Mi
/dev/mmcblk0p2 59G 2.1G 54G 4% /The whole 64 GB card had been expanded into the root filesystem, 116 MB of the 426 MB of memory was in use, and the CPU temperature was 40.6 °C.pizero.local is resolved through mDNS, which Windows 11 supports out of the box, so no extra software is needed.
The Zero W takes nearly 5 minutes from power-on to appearing on the LAN, so when the name does not resolve, rather than repeating ping pizero.local, it is faster to check arp -a on the PC and look for the Raspberry Pi's MAC address (on my unit it started with b8-27-eb).
As long as the default password is in use, a warning appears at every login, so run passwd at the first login and change it.
Step 4: Connect the servo to GPIO12 and drive it with software PWM first
Now for the main part. First, Figure 1 shows the Zero W's pinout. It was drawn from the output of the pinout command (bundled with gpiozero) on the actual board.

The servo itself has three wires. The signal wire goes to GPIO12 (physical pin 32), and power and GND go to the external 5 V supply (a 5 V, 2 A switching supply here). To give the PWM signal a common reference, the negative side of the external supply is also connected to the Zero W's GND at physical pin 30. That makes four connections: signal, external +5 V to the servo, external GND to the servo, and external GND to pin 30 of the Zero W. The positive side of the external supply is not connected to the Zero W's 5 V pins (physical pins 2 and 4); only GND is shared. To suppress the supply sag every time the servo moves, I placed a 100 µF electrolytic capacitor and a 0.1 µF ceramic capacitor in parallel between the supply's +5 V and GND.
Electrolytic capacitors are polarized, so connect the positive lead to +5 V and the negative lead to GND (use one rated above 5 V). At first I powered the servo from the Zero W's 5 V pin (physical pin 2), and a single small hobby servo did work in practice, but it is safer to separate the servo supply from the Zero W, so I switched to the external supply. Never power a servo from the 3.3 V pins. I chose GPIO12 for a reason: it is a pin that can be used later when switching to hardware PWM.


The 2026-06-18 release of Raspberry Pi OS Lite 32-bit used here ships with Python's gpiozero and lgpio preinstalled. With no additional installs, using gpiozero's Servo class with the lgpio pin factory drives the servo with 50 Hz software PWM. The script simply sweeps back and forth, alternating between 1.0 ms and 2.0 ms pulse widths every 2 seconds.
app/sweep2.py(Full file. The comments are in English because the code is shared with this English edition)
#!/usr/bin/env python3
"""Sweep a servo on GPIO12 between two positions every 2 s using SOFTWARE PWM (gpiozero + lgpio, 50 Hz).
Software PWM timing can vary under Linux scheduling; on this single-core Zero W the servo visibly trembled.
Compare with sweep2_hw.py (hardware PWM).
"""
import time, signal, sys
from gpiozero import Servo
from gpiozero.pins.lgpio import LGPIOFactory
PIN = 12
# 1.0 ms / 2.0 ms pulses are the two end positions (the usual range for hobby servos).
# For a wider swing use min_pulse_width=0.5/1000, max_pulse_width=2.5/1000.
servo = Servo(PIN, pin_factory=LGPIOFactory(),
min_pulse_width=1.0/1000, max_pulse_width=2.0/1000, frame_width=20/1000)
POS_A, POS_B = -1.0, 1.0 # -1 = min_pulse_width, +1 = max_pulse_width
INTERVAL = 2.0
def stop(*_):
servo.detach() # stop the signal (servo relaxes)
sys.exit(0)
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
print(f"GPIO{PIN}: {POS_A} <-> {POS_B} every {INTERVAL}s (Ctrl+C / SIGTERM to stop)", flush=True)
pos = POS_A
while True:
servo.value = pos
print(time.strftime("%H:%M:%S"), "pos", pos, flush=True)
time.sleep(INTERVAL)
pos = POS_B if pos == POS_A else POS_ATransfer it from the repository folder on Windows and start it in the background. Create the destination directory on the Zero W first.
ssh pi@pizero.local "mkdir -p ~/servo"
scp app\sweep2.py app\sweep2_hw.py pi@pizero.local:servo/
ssh pi@pizero.local 'cd servo && (nohup python3 sweep2.py > sweep2.log 2>&1 &)'The servo moved: right, then left, every 2 seconds. But while holding position, it twitched with a fine tremor.
You can see it in the first half of the video below. It is visibly shaking.
This is software PWM jitter. With software PWM, the CPU keeps time and generates the pulses, so Linux scheduling and other workloads can shift the timing of the rising and falling edges. On this Zero W, the fine tremor that appears to result from this was plainly visible. I did not measure the pulse-width jitter itself with an oscilloscope, so I will not quote a figure here. In my setup, gpiozero also printed the warning PWMSoftwareFallback at startup, telling me it was running on software PWM.
One more small trap I hit here. If you stop the script with pkill -f sweep2.py, the command line of the very shell you are running over SSH also contains sweep2.py, so it kills itself and ssh silently drops with exit code 255. Use the exact-match form pkill -xf "python3 sweep2.py" instead.
Step 5: Switch to hardware PWM and stop the jitter
GPIO12 is a pin connected to the SoC's PWM0 circuit. Enabling it through a device tree overlay makes the SoC's built-in PWM hardware generate the pulses instead of the CPU, which makes them far less sensitive to CPU load and scheduling. No extra packages are needed: add one line of configuration and reboot.pin=12The 12 in that line is the BCM GPIO number, not the physical pin number (32).
echo 'dtoverlay=pwm,pin=12,func=4' | sudo tee -a /boot/firmware/config.txt
sudo rebootAfter the reboot, in this environment /sys/class/pwm/pwmchip0 appears and the pinctrl get 12 output changes to GPIO12 = PWM0. After that, writing numbers to sysfs is all it takes to move the servo. The pi user is in the gpio group, so sudo is not needed.
echo 0 > /sys/class/pwm/pwmchip0/export
sleep 0.3 # wait for the pwm0 directory to appear and get gpio group permissions
echo 20000000 > /sys/class/pwm/pwmchip0/pwm0/period # 20ms = 50Hz
echo 1500000 > /sys/class/pwm/pwmchip0/pwm0/duty_cycle # 1.5ms = center
echo 1 > /sys/class/pwm/pwmchip0/pwm0/enableThe hardware PWM version of the sweep script just writes to this sysfs interface instead of using gpiozero.
app/sweep2_hw.py(Excerpt. See the GitHub repository for the full file)
CHIP = "/sys/class/pwm/pwmchip0"
PERIOD_NS = 20_000_000 # 20ms = 50Hz
PULSE_A_NS = 1_000_000 # 1.0ms
PULSE_B_NS = 2_000_000 # 2.0ms
def w(path, val):
with open(path, "w") as f:
f.write(str(val))
w(f"{CHIP}/pwm0/period", PERIOD_NS)
w(f"{CHIP}/pwm0/duty_cycle", PULSE_A_NS)
w(f"{CHIP}/pwm0/enable", 1)
pulse = PULSE_A_NS
while True:
w(f"{CHIP}/pwm0/duty_cycle", pulse)
time.sleep(INTERVAL)
pulse = PULSE_B_NS if pulse == PULSE_A_NS else PULSE_A_NSStart it in the background from the same location as in Step 4. On startup the script first sets enable=0 before writing the period and pulse width, and when stopped (SIGTERM) it sets enable=0 again to stop the signal.
cd ~/servo && (nohup python3 sweep2_hw.py > sweep2_hw.log 2>&1 &)
# To stop it (use exact match; same reason as the trap in Step 4)
pkill -xf "python3 sweep2_hw.py"The jitter is gone.
See the second half of the earlier video.
It clicks into position every 2 seconds and stays perfectly still.pinctrl get 12 output and the sysfs values, measured at that point, are shown below. Before moving on to the API server in the next step, stop the script with the pkill command above. If a process is still holding GPIO12, the API server cannot use the same pin.
12: a0 -- | lo // GPIO12 = PWM0
period=20000000 duty_cycle=1000000 enable=1Incidentally, according to the official overlay list, the main pins that can output hardware PWM are GPIO12 and GPIO18 (PWM0), and GPIO13 and GPIO19 (PWM1). The on-board analog audio output also uses both PWM channels, so care is needed when combining them with audio. That does not matter for this headless Lite setup. If you want to drive a servo with low jitter on other pins, one option is to install pigpio, which generates the timing with DMA. I wanted to avoid any additional installs, so I chose a hardware PWM pin.
Step 6: Set up an API server that switches between "jittery" and "steady"
I wanted to be able to feel the difference in jitter with my own fingers from my PC or phone. So I put a small server on the Zero W that switches between software PWM and hardware PWM through an HTTP API. The HTTP server part is implemented with Python's standard library only, and GPIO control uses the gpiozero and lgpio that ship with this OS image, so nothing needs to be installed.
There are only two API endpoints.
| Endpoint | Role |
|---|---|
GET /api/status | Returns the current mode, the state of GPIO12 (the raw output of pinctrl get 12), the CPU temperature, and more as JSON |
POST /api/mode | Accepts {"mode":"soft"} / {"mode":"hard"} / {"mode":"off"}, switches the mode, and returns the state after the change |
In every mode the servo sweeps between 1.0 ms and 2.0 ms pulses every 2 seconds, so the motion is identical and only the jitter differs. In Part 2, I plan to add an API that sets the angle, and finally turn this into an MCP server so that ChatGPT can move it directly.
The tricky part of the implementation is the order of operations when switching modes back and forth on the same GPIO12. Entering software PWM makes lgpio turn the pin into a plain output pin, so when returning to hardware PWM, pinctrl set 12 a0 restores the pin function to PWM0 before sysfs is enabled. Without this one line, nothing comes out even after writing to sysfs.
app/api_server.py(Mode-switching part, excerpt. See the GitHub repository for the full file)
def _enter(self, mode):
if mode == "soft":
self._factory = LGPIOFactory()
self._servo = Servo(PIN, pin_factory=self._factory,
min_pulse_width=PULSE_A_NS / 1e9, max_pulse_width=PULSE_B_NS / 1e9,
frame_width=PERIOD_NS / 1e9)
elif mode == "hard":
pinctrl("set", str(PIN), "a0") # lgpio leaves the pin as input/output: put it back to PWM0
if not os.path.exists(PWM_DIR):
sysfs_write(f"{PWM_CHIP}/export", PWM_CH)
time.sleep(0.3) # wait for udev to apply the gpio-group permissions
sysfs_write(f"{PWM_DIR}/enable", 0)
sysfs_write(f"{PWM_DIR}/period", PERIOD_NS)
sysfs_write(f"{PWM_DIR}/duty_cycle", self.pulse_ns)
sysfs_write(f"{PWM_DIR}/enable", 1)
def _leave(self, mode):
if mode == "soft":
self._servo.detach(); self._servo.close(); self._factory.close()
elif mode == "hard":
sysfs_write(f"{PWM_DIR}/enable", 0)Only a single worker thread touches the GPIO; the HTTP side merely posts a request saying "the next mode is this". That way, GPIO operations never interleave even if several requests arrive at once. The server listens only on 127.0.0.1:18080 and is not exposed directly to the LAN. In the next step, the WireCanal Agent delivers outside access to this loopback address, so there is no need to open an inbound internet-facing port on the router or firewall.
Here too there was a Zero W-specific trap. In the first version, the API response for switching to software PWM came back still showing the old mode. The cause was that the first import of gpiozero takes several seconds on the Zero W, longer than the time allowed for the switch to complete. Doing the import at startup fixed it.
It runs as a systemd service. Running it as the pi user gives it gpio group permissions to write to sysfs.
systemd/servo-api.service(Full file)
[Unit]
Description=Pi Zero W servo demo API server (127.0.0.1:18080, hosts web UI)
After=network.target
[Service]
User=pi
WorkingDirectory=/home/pi/servo_demo
ExecStart=/usr/bin/python3 /home/pi/servo_demo/api_server.py
Restart=always
RestartSec=3
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.targetFirst, transfer app and systemd from the repository folder on Windows to the Zero W.
ssh pi@pizero.local "mkdir -p ~/servo_src"
scp -r app systemd pi@pizero.local:servo_src/The rest is done on the Zero W.
cd ~/servo_src
mkdir -p ~/servo_demo && cp -r app/api_server.py app/web ~/servo_demo/
sudo install -m 644 systemd/servo-api.service /etc/systemd/system/servo-api.service
sudo systemctl daemon-reload
sudo systemctl enable --now servo-api
curl -s http://127.0.0.1:18080/healthz # okOn the Zero W, first hit the API with a local curl. Getting the status looks like this.
curl -s http://127.0.0.1:18080/api/status{"mode": "off", "requested": "off", "pulse_ms": 1.0, "switches": 0, "since": 1788363789.7433455, "last_error": "", "pin": "12: ip -- | lo // GPIO12 = input", "board": {"hostname": "pizero", "arch": "armv6l", "model": "Raspberry Pi Zero W Rev 1.1", "cpu_temp_c": 41.2, "uptime_s": 8829}, "server_uptime_s": 103}Switching the mode is a POST. When switching to hardware PWM, pin in the response becomes GPIO12 = PWM0 and the servo starts sweeping.
curl -s -X POST -H "Content-Type: application/json" -d '{"mode":"hard"}' http://127.0.0.1:18080/api/mode{"mode": "hard", "requested": "hard", "pulse_ms": 2.0, "switches": 1, "since": 1788364513.3218658, "last_error": "", "pin": "12: a0 -- | lo // GPIO12 = PWM0", "board": {"hostname": "pizero", "arch": "armv6l", "model": "Raspberry Pi Zero W Rev 1.1", "cpu_temp_c": 41.2, "uptime_s": 9464}, "server_uptime_s": 738}At this point, pinctrl get 12 and the sysfs values show that PWM0 is enabled and emitting 2.0 ms pulses at a 20 ms period.
$ pinctrl get 12
12: a0 -- | lo // GPIO12 = PWM0
$ cat /sys/class/pwm/pwmchip0/pwm0/period /sys/class/pwm/pwmchip0/pwm0/duty_cycle /sys/class/pwm/pwmchip0/pwm0/enable
20000000 2000000 1To stop it, just send {"mode":"off"}.
curl -s -X POST -H "Content-Type: application/json" -d '{"mode":"off"}' http://127.0.0.1:18080/api/modeAs a bonus, the same server also serves a small browser page. The page only calls this API via fetch from JavaScript and knows nothing about GPIO. When angle control is added in Part 2, the page will not need to change either.

Step 7: Create a WireCanal canal and give the Zero W a public URL
Now to expose it to the internet. I use our own WireCanal. The Zero W only makes an outbound connection and gets a public HTTPS URL in return, with no router port forwarding and no static IP. I proceed on the free plan.
app.wirecanal.com is where you log in. Click "Create a new canal" and a four-step wizard starts.

Choose HTTP as the type, "auto-assigned subdomain" as the public address, and enter the Zero W API server's localhost:18080 as the forwarding target.




Once created, the canal details page shows the public URL and the connection file wirecanal.json. This file contains the connection key for that canal, so download it with "Download wirecanal.json" and move it from your downloads folder to the Zero W's home directory.
scp .\wirecanal.json pi@pizero.local:~/The "Linux" tab on the same page shows the exact commands used in the next step.

Step 8: Install the Agent on the Zero W and run it under systemd
The work on the Zero W side follows the official Linux setup guide. The installer detects the CPU type (armv6l here) automatically, so it installs with the same one-liner as on the Raspberry Pi 5.
sudo mkdir -p /opt/wirecanal && cd /opt/wirecanal
curl -fsSL https://download.wirecanal.com/install.sh | sudo shDownloading WireCanal Agent v.0.18.3...
wirecanal v.0.18.3 powered by Qualiteg Inc. https://qualiteg.com
Installed: /opt/wirecanal/wirecanal
Next steps:
1. Download wirecanal.json from the canal details page on the dashboard
(https://app.wirecanal.com) and place it in the same folder as wirecanal
2. Start: ./wirecanal -config wirecanal.jsonWhat got installed was a statically linked 32-bit ARM binary with no runtime dependencies, 9.8 MB in size.
$ file /opt/wirecanal/wirecanal
/opt/wirecanal/wirecanal: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linkedPlace the wirecanal.json you moved to the home directory in Step 7 into the same directory, and prepare a dedicated user and the systemd unit template./opt/wirecanal is owned by root, so copy the file from the home directory with sudo install. The template is the one from the official guide, used as is.
sudo install -m 600 ~/wirecanal.json /opt/wirecanal/wirecanal.json
sudo useradd --system --home /opt/wirecanal --shell /usr/sbin/nologin wirecanal
sudo chown -R wirecanal: /opt/wirecanal/etc/systemd/system/wirecanal.service(Full file)
[Unit]
Description=WireCanal Agent
After=network-online.target
Wants=network-online.target
[Service]
User=wirecanal
WorkingDirectory=/opt/wirecanal
ExecStart=/opt/wirecanal/wirecanal -config /opt/wirecanal/wirecanal.json
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now wirecanal
journalctl -u wirecanal -fThe startup log. Two seconds after starting, the "delivering" line appeared and the canal was live.
wirecanal v.0.18.3 powered by Qualiteg Inc. https://qualiteg.com
wirecanal: auto-update is enabled (to disable, set "auto_update": false in wirecanal.json)
wirecanal: canal d7t2: delivering access to https://5jk3hvty.ja000.wirecanal.com to the local forward target localhost:18080
wirecanal 0.18.3: connecting tenant=d7t2 mode=http forward_target=localhost:18080The dashboard also changes to "Connected".

Hitting it with curl from the other side of the internet
Yes! This is the goal of this article.
I hit the same API I used on the Zero W, this time from my Windows PC against the public URL. The PC is on the same home LAN as the Zero W, but the public URL goes through WireCanal's edge (Tokyo), so the path is over the internet. Only the URL changes; the commands are almost the same. The one difference is that on Windows you write curl.exe with the extension. In Windows PowerShell 5.1, curl is an alias for Invoke-WebRequest, which hides the real curl. That is also why I wrote curl.exe for the download in Step 1.
curl.exe -s https://5jk3hvty.ja000.wirecanal.com/api/status{"mode": "off", "requested": "off", "pulse_ms": 1.0, "switches": 0, "since": 1788363789.7433455, "last_error": "", "pin": "12: ip -- | lo // GPIO12 = input", "board": {"hostname": "pizero", "arch": "armv6l", "model": "Raspberry Pi Zero W Rev 1.1", "cpu_temp_c": 41.2, "uptime_s": 8829}, "server_uptime_s": 103}Switching modes works the same way. From my PC, I start the sweep in hardware PWM.
curl.exe -s -X POST -H "Content-Type: application/json" -d '{"mode":"hard"}' https://5jk3hvty.ja000.wirecanal.com/api/mode{"mode": "hard", "requested": "hard", "pulse_ms": 1.0, "switches": 1, "since": 1788364513.3218658, "last_error": "", "pin": "12: a0 -- | hi // GPIO12 = PWM0", "board": {"hostname": "pizero", "arch": "armv6l", "model": "Raspberry Pi Zero W Rev 1.1", "cpu_temp_c": 39.5, "uptime_s": 9449}, "server_uptime_s": 723}The pin in the response becomes GPIO12 = PWM0, and the servo next to the Zero W clicks into motion. Switching to software PWM makes it GPIO12 = output, and it sweeps with a tremor.
curl.exe -s -X POST -H "Content-Type: application/json" -d '{"mode":"soft"}' https://5jk3hvty.ja000.wirecanal.com/api/mode{"mode": "soft", "requested": "soft", "pulse_ms": 1.0, "switches": 2, "since": 1788364534.6957178, "last_error": "", "pin": "12: op -- -- | lo // GPIO12 = output", "board": {"hostname": "pizero", "arch": "armv6l", "model": "Raspberry Pi Zero W Rev 1.1", "cpu_temp_c": 40.1, "uptime_s": 9470}, "server_uptime_s": 744}Finally, stop it.
curl.exe -s -X POST -H "Content-Type: application/json" -d '{"mode":"off"}' https://5jk3hvty.ja000.wirecanal.com/api/modeThe syntax above is for PowerShell 7. In Windows PowerShell 5.1, the double quotes inside the JSON are stripped before they reach curl.exe, so escape them with backslashes, like -d '{\"mode\":\"hard\"}'. Passing it unescaped in 5.1 made the API return {"error": "mode must be one of off/soft/hard"}, as I confirmed (the same syntax works in 7).
{"mode": "off", "requested": "off", "pulse_ms": 2.0, "switches": 3, "since": 1788364558.2063227, "last_error": "", "pin": "12: ip -- | lo // GPIO12 = input", "board": {"hostname": "pizero", "arch": "armv6l", "model": "Raspberry Pi Zero W Rev 1.1", "cpu_temp_c": 39.0, "uptime_s": 9494}, "server_uptime_s": 768}If curl can hit it, so can a script, a cron job, or a CI pipeline. Turning it into an MCP server in Part 2 is an extension of the same idea.
The Agent log on the Zero W recorded the access from outside as it happened.
wirecanal: access: 2026-09-02 21:23:43 xxx.xxx.xxx.xxx GET / → 200
wirecanal: access: 2026-09-02 21:23:43 xxx.xxx.xxx.xxx GET /api/status → 200
wirecanal: access: 2026-09-02 21:23:44 xxx.xxx.xxx.xxx POST /api/mode → 200Open the public URL in a browser and the same page appears. It is built so that the buttons are large enough to tap at phone width, so pressing "Software PWM" and "Hardware PWM" in turn on an iPhone lets you feel the difference in jitter at your fingertips.


After a reboot, it came back all the way to the public URL on its own
Since both the API server and the Agent run under systemd, everything should come back after a reboot without any intervention.sudo systemctl reboot was run, and I started timing from that moment.
| Check item | Measured result |
|---|---|
| Agent reconnection | The "delivering" line appeared 83 seconds after the reboot and the Agent connected |
| SSH recovery | About 110 seconds after the reboot. Both the servo-api and wirecanal services were active |
| Public URL | HTTP 200 (0.11 s response) |
| GPIO12 | Right after the reboot, pinctrl get 12 reports PWM0 (the overlay in config.txt remains in effect after the reboot, and the API server does not touch the pin in off mode). Once software PWM has been used, the pin reads input even in off mode after lgpio releases it (that is what the JSON in the text shows) |
It came back to the public URL without my touching anything. I did not time a full power cycle this time, but since the same systemd configuration starts everything automatically, as a rule of thumb wait about 2 minutes after powering on before opening the URL.
Summary of the measurements
| Item | Measured value |
|---|---|
| OS image | 524 MB compressed, 2,552 MB expanded (32-bit Lite, trixie) |
| SD card write | 107 s write + 67 s verify = 184 s |
| Power-on to SSH login | About 5 minutes (including the automatic reboot) |
| Agent binary | 9.8 MB, statically linked, armv6l auto-detected |
| Agent start to canal live | 2 seconds |
| Response time from the browser | 80–120 ms (status API) |
| Mode switch applied | About 1 second |
| Reboot to public URL restored | Agent connected in 83 s, all services running in about 110 s |
| Memory and temperature (idle) | 116 MB of 426 MB in use, CPU 37–40 °C |
Pitfalls and workarounds
| Symptom | Cause | Workaround |
|---|---|---|
| Imager exits after 4 seconds without doing anything, and no error is shown | The destination was written as \.\PhysicalDrive1, missing a backslash. The rejection message goes only to standard error | Write \\.\PhysicalDriveN. If the log has no startWrite line, it failed at the argument stage |
| Windows does not recognize bootfs after the write | Stale partition information cache | Run rescan in an elevated diskpart, or remove and reinsert the SD card |
| pizero.local does not resolve | The Zero W takes nearly 5 minutes to appear on the LAN | Wait. Looking for the Raspberry Pi's MAC with arp -a is faster (on my unit it started with b8-27-eb) |
| The servo twitches | Timing jitter of software PWM (observed on this Zero W) | Switch to hardware PWM with dtoverlay=pwm,pin=12,func=4 |
| It still twitches | Possibly insufficient power or voltage drop. When powered from the Zero W's 5 V pin, the voltage can sag every time the servo moves | Use a separate 5 V supply for the servo and share only GND with the Zero W (the wiring in this article) |
pkill -f silently drops the ssh session | The pattern also matches your own shell's command line, so it kills itself | Use the exact-match form pkill -xf "python3 sweep2.py" |
| The servo does not move after switching back from soft to hard | lgpio left the pin configured as output/input | Restore PWM0 with pinctrl set 12 a0 before enabling sysfs |
| The first mode switch through the API returns the old mode | The first import of gpiozero takes several seconds on the Zero W | Do the import at startup |
Even a 2017 board became a servo you can reach from outside
The Raspberry Pi Zero W is a single-core board with 512 MB of RAM and 2.4 GHz Wi-Fi only. Even so, everything needed to go from writing the SD card to moving a servo over the internet is in this article. No inbound internet-facing port was ever opened on the router or firewall (the API server listens only on 127.0.0.1:18080 on the Zero W), and WireCanal stayed on the free plan.
What differed from the Raspberry Pi 5 was using the 32-bit OS, waiting 5 minutes for the first boot, and stopping the servo jitter with hardware PWM. Conversely, what the Agent does is exactly the same: install.sh detects armv6l and places the binary, you put wirecanal.json next to it, and start it. That is all.
This time it was a single servo, but the same setup applies as is to other devices that can be controlled through GPIO and the like.
In Part 2, I will add an API that sets the angle so you can move the servo to any position with curl, and finally turn this Zero W into an MCP server so that ChatGPT can move it directly!
See you next time!
Sources and references
- Raspberry Pi Imager (official download page)
- Raspberry Pi OS image list (official download site; the 32-bit Lite edition is here too)
- config.txt reference (official Raspberry Pi documentation)
- Device tree overlay list README (raspberrypi/firmware on GitHub; covers the pin and func combinations for the pwm overlay and the caveat about sharing with audio)
- The gpiozero Servo class (official documentation)
- Sample code (GitHub: qualiteg/wirecanal-iot-demo-raspberry-pi-zero-w-servo, at the commit current when this article was written)
- WireCanal Linux setup guide (installing the Agent and running it under systemd)
- WireCanal IoT device support page (supported chip and Linux combinations, and examples of devices with supported chips)
Related articles
- Setting Up a Raspberry Pi 5 Without a Monitor: From OS Imaging to SSH Using Only a Windows PC
- Hardening a Raspberry Pi 5: SSH keys, UFW, fail2ban — and the trap where IPv6 comes back after a reboot
- Publishing a Raspberry Pi 5 Web Server to the Internet: No Open Ports, Just a WireCanal Tunnel
- Using a Luckfox Pico M from Anywhere: Putting a Public URL on a $25, 64 MB Linux Board with WireCanal