A few weekends ago I got fed up with the same Windows web-dev ritual and wrote my own control panel. The result is GoAMPP — a 7 MB single-binary native Win32 app that knows how to download and manage Apache, Nginx, MariaDB, PostgreSQL, Redis, PHP, phpMyAdmin, Adminer, plus four language runtimes (Node.js, Python, Go, Java) and seventeen framework scaffolders.
Repo: github.com/imtaqin/goampp
This post is a brain dump of the design choices I made, the bugs I hit, and the parts of Win32 that bit me along the way. Hopefully somebody else building a desktop app in Go finds the post-mortem useful.
Why not just use XAMPP
Three reasons I ran out of patience with the existing options:
- The installers are huge. XAMPP for Windows is around 200 MB downloaded, more after install, even though I only ever use Apache + MariaDB + PHP + phpMyAdmin. The rest is dead weight.
- Versions go stale. When PHP 8.5.0 dropped, the bundled version in XAMPP took months to catch up. Same story with MariaDB LTS bumps.
- The control panels are fragile. XAMPP's panel hangs when Apache crashes mid-startup and the pid file is stale. Laragon is better but ships with Cmder, a Telegram notifier, and a bunch of stuff I didn't ask for.
The mental model I wanted: ship a tiny control panel, fetch the services on demand, cache them locally, and never bundle anything I'm not actively using. Closer to a package manager than an installer.
The architecture in 30 seconds
GoAMPP is a single Go binary built with -ldflags="-H windowsgui -s -w" so it has no console window and is stripped down to ~7 MB. The GUI uses windigo, a pure-Go binding for the Win32 widget set — no CGO, no webview, no Electron.
Inside the binary there's a DownloadCatalog map keyed on service name. Each entry has the download URL, the install directory, an optional StripTop for archives that wrap everything in a top-level folder, and a PostInstall hook for things like running mariadb-install-db.exe or patching httpd.conf.
Screenshot
"Apache": {
Version: "2.4.66 (VS18, win64)",
URL: "https://www.apachelounge.com/download/VS18/binaries/httpd-2.4.66-260223-Win64-VS18.zip",
InstallDir: "bin/apache",
StripTop: "Apache24/",
Kind: "zip",
CheckFile: "bin/httpd.exe",
PostInstall: func(installDir string, log func(string)) error {
// patch httpd.conf to fix SRVROOT, ServerName, mod_cgi, etc.
},
},
When you click Start on a service whose binary isn't on disk yet, the flow is:
- Check the catalog for an entry. Bail if missing.
- Spawn a goroutine so the UI stays responsive.
- Stream the zip into
downloads/<filename>.part. Atomic rename on success. - Walk the archive entries, strip
StripTopif set, write tobin/<service>/. Guard against zip-slip — refuse any entry whose resolved path escapes the destination. - Run the post-install hook.
- Start the process.
The download progress fires through a ProgressFunc callback that the UI uses to drive a ProgressBar widget at ~30 Hz. Text logging fires at 1 Hz in parallel because writing to a multi-line Edit control is expensive — every line is a full WM_SETTEXT round trip.
The Apache bug I lost an evening to
This one's worth its own section because it's the kind of bug that doesn't show up on Linux at all.
The first version of GoAMPP used mod_proxy_fcgi with the standard idiom:
<FilesMatch \.php$>
SetHandler "proxy:fcgi://127.0.0.1:9000"
</FilesMatch>
Apache logs the script's full filesystem path in the upstream URL. On Linux that produces something sane like fcgi://127.0.0.1:9000/var/www/script.php. On Windows, the script path includes a drive letter (C:/...), and Apache concatenates without inserting a slash:
fcgi://127.0.0.1:9000 + C:/Users/.../script.php
= fcgi://127.0.0.1:9000C:/Users/.../script.php
The proxy URL parser then sees host=127.0.0.1, port=9000C, path=/Users/..., and the DNS resolver loses its mind:
AH00898: DNS lookup failure for: 127.0.0.1:9000c
This is Apache bug 55345, open since 2013. The workaround documented in the comments is to use ProxyPassMatch with the docroot baked into the URL, but I tested that route and PHP-CGI then complained "No input file specified" because the SCRIPT_FILENAME ended up with a leading slash that Windows refused to resolve.
The fix that actually works is to use mod_cgi + mod_actions + a ScriptAlias to point at php-cgi.exe directly. It's the classic CGI approach XAMPP has used for fifteen years. Slightly slower per request than FastCGI but bulletproof on Windows because there's no proxy URL to construct in the first place:
LoadModule cgi_module modules/mod_cgi.so
LoadModule actions_module modules/mod_actions.so
ScriptAlias "/__goampp-php-bin__/" "C:/.../bin/php/"
<Directory "C:/.../bin/php">
AllowOverride None
Options +ExecCGI
Require all granted
</Directory>
AddHandler application/x-httpd-php .php
Action application/x-httpd-php "/__goampp-php-bin__/php-cgi.exe"
Lesson learned: when a Windows Apache config uses proxy:fcgi://, run tail -f error_log and look for 9000c in the DNS error messages. It's never your firewall.
Process management on Windows is harder than it looks
The first time I clicked Stop on Apache, the process exited and the log said [Apache] exited cleanly. Two seconds later I clicked Start again and got port 80 already in use. Reboot. Repeat. The bug took a while to find.
Here's what was happening: Apache's winnt MPM forks a worker child. When you cmd.Process.Kill() the parent, the child keeps running because Windows doesn't have process groups in the POSIX sense. The orphaned worker holds the listening socket on port 80 until you manually kill it via Task Manager.
Fix: use taskkill /F /T /PID <pid> instead of cmd.Process.Kill(). The /T flag ("kill tree") nukes the parent and every descendant in one shot. Since GoAMPP launches services with a known parent PID, the tree kill catches the worker too.
I also added a startup sweep that walks all processes via PowerShell (Get-Process | Where-Object { $_.Path }) and kills anything whose image path is inside <goampp>/bin/. That cleans up zombies left from prior crashes — say, when Apache died because httpd.conf had a syntax error and the worker was already running. Without the sweep, the next launch would hit "port 80 in use" and look broken.
I originally used wmic for the sweep, but Windows 11 22H2+ removed it from the default install. PowerShell ships with every modern Windows so it's a safer dependency.
Native ListView groups: fragile
I wanted the Services view to render with collapsible category headers (Web Servers / Languages / Databases / Tools) using native ListView groups. Win32 supports them via LVM_ENABLEGROUPVIEW + LVM_INSERTGROUP. windigo doesn't expose either, so I tried to do it via raw SendMessage.
The LVGROUP struct has a documented Vista+ size of 148 bytes on 32-bit. On 64-bit, with 8-byte pointers and Go's struct padding, unsafe.Sizeof() returns 152. I tried both values for cbSize. Both rejected. The Common Controls v6 DLL on Win11 silently returns -1 from LVM_INSERTGROUP for reasons I never tracked down.
Eventually I gave up and built a hand-rolled card grid: 12 child Control containers with their own Static for the icon, Static for the name, and four Button widgets per card. Each card's button-click handler captures the source service index in a closure, so I never need a "currently selected service" lookup. The result is more code than the ListView would have been but it actually works and the per-card buttons read better visually.
Sometimes the right answer is "give up on the fancy widget and draw it yourself".
The icons-on-cards trick
Each card has a 32×32 service logo on the left. Apache, Nginx, PHP, MariaDB, PostgreSQL, Redis, phpMyAdmin, Adminer, plus all four runtimes — twelve logos total.
windigo's icon API only supports icons embedded as Win32 resources, not files on disk. I needed to load .ico files at runtime, so the workflow ended up:
- A build-time tool (
tools/makelogos) reads a PNG fromicon/<name>.png, resizes it to 32×32 withgolang.org/x/image/draw's Catmull-Rom kernel, encodes the result as a PNG, wraps it in a single-entry ICO header, and writesassets/icons/<name>.ico. - At runtime,
installServiceIcons()callswin.HINSTANCE(0).LoadImage(ResIdStr(icoPath), IMAGE_ICON, 32, 32, LR_LOADFROMFILE)for each service and stores the resultingHICONin a per-name map. - Each card's
SS_ICONStatic gets its icon set viaSendMessage(STM_SETICON, hIcon, 0)— windigo doesn't exposeSTM_SETICONso I define the constant (0x0170) inline and dialSendMessagedirectly.
Modern Windows accepts PNG-encoded entries inside ICO files, which means each .ico is roughly the size of the resized PNG plus 22 bytes of header. The whole assets/icons/ directory is ~18 KB.
What's in the box today
- Services: Apache 2.4.66, Nginx 1.28.3, MariaDB 11.4.10 LTS, PostgreSQL 18.3, Redis 5.0.14.1, PHP 8.5.5 NTS, phpMyAdmin 5.2.3, Adminer 5.4.2
- Runtimes: Node.js 22 LTS, Python 3.13 (embeddable, with auto-bootstrapped pip), Go 1.26, Eclipse Temurin JDK 21
- Frameworks: Laravel, Laravel + Livewire, Symfony, CodeIgniter 4, WordPress, Next.js, Vite + React, Express, NestJS, AdonisJS, Flask, Django, FastAPI, Go net/http, Gin, Spring Boot, Static HTML
- System: auto-vhost (writes hosts file + Apache vhosts.conf), system tray, minimize-to-tray, auto-start at Windows boot, "Add tools to PATH" (writes user PATH in HKCU registry), built-in text editor for service config files
The installer is 5.4 MB, per-user (no admin needed), and lives at github.com/imtaqin/goampp/releases.
What I'd do differently
A few honest retrospectives:
- Pick a UI library that doesn't make you fight padding. windigo is great for what it is, but laying out 12 cards in a 4×3 grid by hand-positioning every Static and Button taught me a lot about why Flutter and SwiftUI exist.
- Write the elevation helper from day one. I shipped the first version without a "Restart as Admin" button and immediately needed it the first time I tried to write the hosts file. Adding it later was easy; explaining to users why the vhost Apply button silently failed was not.
-
Don't assume
wmicis on every Windows install. Past me would have been less embarrassed by an empty.gitignorethan by a startup sweep that silently no-op'd on every Win11 22H2 machine.
If you're building a desktop tool in Go and you want native Win32 widgets without the Electron tax, the answer is windigo + a lot of patience with WM_* messages. Total code for GoAMPP including the installer script is around 6,000 lines.
Try it
Latest installer: github.com/imtaqin/goampp/releases/latest (5.4 MB)
Source: github.com/imtaqin/goampp
Issues, PRs, and questions all welcome. If you hit a download URL that's gone stale, file an issue with the service name and I'll bump the catalog.


Top comments (0)