Build APM on Windows, Part 3: Apache httpd 2.4.69 with CMake
Apache httpd has no official installer for Windows. You have to compile it yourself or rely on someone else's package. This time I compile the whole set in 4 minutes, then have the script check that all required DLLs are present and none accidentally link to debug DLLs.
When I first wrote 20-apache.ps1, it produced httpd.exe on the first run. But when I checked closely, I found that mod_md.so linked to libcurl-d.dll, a debug DLL that would never be on anyone's machine except the build machine. Without that check, I would only have found out after trying to start it on another machine and seeing the module fail to load. That is why the final script checks DLLs with dumpbin and fails immediately if it finds a debug dependency. In Part 3, I will walk through the entire script, every CMake option, and the commands for checking the results one by one.
Before continuing, you must have finished Part 2: the source must be in C:\apm-build\src, and the libraries built by vcpkg must be in C:\apm-build\build\vcpkg_installed\x64-windows. If they are not there yet, go back and finish Part 2 first, because this script does not download anything.
1. Preparation: check the files from Part 2
Run every step in pwsh (PowerShell 7) as a regular user. You do not need Administrator privileges or a Developer Prompt, because the script loads the MSVC environment itself.
Step 1.1 Open pwsh and enter the kit folder
Set-Location C:\apm-build
Step 1.2 Check that all Apache source directories are present
Test-Path C:\apm-build\src\httpd-2.4.69\CMakeLists.txt
Test-Path C:\apm-build\src\apr-1.7.6\CMakeLists.txt
Test-Path C:\apm-build\src\apr-util-1.6.5\CMakeLists.txt
Test-Path C:\apm-build\src\mod_fcgid-2.3.9\modules\fcgid\CMakeLists.txt
You should get four lines of True. If any result is False, 00-fetch.ps1 has not finished, or the files were extracted at the wrong directory level. Go back to Part 2, Step 4.5.
Step 1.3 Check that the vcpkg libraries are present
Test-Path C:\apm-build\build\vcpkg_installed\x64-windows\lib\libssl.lib
Test-Path C:\apm-build\build\vcpkg_installed\x64-windows\lib\pcre2-8.lib
Test-Path C:\apm-build\build\vcpkg_installed\x64-windows\bin\libcrypto-3-x64.dll
You should get three lines of True.
Step 1.4 Check that CMake is recent enough
The script uses cmake --fresh, which has been available since CMake 3.24.
cmake --version | Select-Object -First 1
ninja --version
cmake version 4.1.2
1.13.1
Your version may be newer than this; CMake just needs to be at least 3.24.
Step 1.5 Check that the script can find Visual Studio
lib.ps1 no longer hardcodes the path to vcvars64.bat. It asks vswhere.exe (installed with the Visual Studio Installer) where Visual Studio 2022 with the C++ x64 tools lives, so it works with any edition (Build Tools, Community, Professional, Enterprise) at any install path. Try the same query the script uses.
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -version '[17.0,18.0)' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
Test-Path "$vs\VC\Auxiliary\Build\vcvars64.bat"
The result should be True. If it is not True, or vswhere prints nothing, there is no Visual Studio 2022 with the "MSVC v143 - VS 2022 C++ x64/x86 build tools" component. Go back to Part 1 and see "If something breaks here."
2. Overview: build 4 components in dependency order
Apache httpd is not a single component. Its dependencies form layers, and you must build them in this order because each later component uses the headers and .lib files that the previous component has just installed into stage\apache.
- APR 1.7.6 (Apache Portable Runtime), the lowest layer, wraps operating system functions for files, sockets, threads and memory so Apache can use the same code on every system.
- APR-util 1.6.5, the layer above APR, adds commonly used features such as XML parsing, cryptography (this uses OpenSSL from vcpkg), and dbm databases.
- httpd 2.4.69, the web server and the modules included in its source (ssl, http2, rewrite, proxy, etc.).
- mod_fcgid 2.3.9, the module Apache uses to communicate with
php-cgi.exe. It is a separate project, outside the httpd source tree (Part 6 will show how to use it).
All four components follow the same sequence: cmake configures the build, cmake --build compiles it, and cmake --install copies the results into C:\apm-build\stage\apache. I use Ninja to run the actual compilation because it is faster and uses every CPU core without extra configuration.
Prebuilt Windows packages lag behind upstream releases longer than I can accept when security patches arrive. Building it myself means I can change the version in versions.json and have binaries built from hash-verified source within the same day. The cost is having to understand what every option in this part does.
3. The complete 20-apache.ps1 script
Read through the whole file once first. You do not need to understand every line yet; below, I will explain it in execution order.
# 20-apache.ps1 - build APR, APR-util, httpd and mod_fcgid with CMake + Ninja (MSVC x64)
# Needs: 00-fetch.ps1 (sources) + 10-deps-vcpkg.ps1 (third-party libs)
$ErrorActionPreference = 'Stop'
$Root = Split-Path $PSScriptRoot -Parent
. "$PSScriptRoot\lib.ps1"
$V = (Get-Content "$Root\versions.json" -Raw | ConvertFrom-Json).sources
$Dep = "$Root\build\vcpkg_installed\x64-windows" -replace '\\','/'
$Pfx = "$Root\stage\apache" -replace '\\','/'
$B = "$Root\build"
$Log = "$B\logs\apache.log"
New-Item -ItemType Directory -Force "$B\logs" | Out-Null
Remove-Item $Log -ErrorAction SilentlyContinue
Enter-VsEnv
# CMAKE_POLICY_VERSION_MINIMUM: APR/httpd/fcgid declare cmake_minimum_required < 3.5, which CMake 4.x rejects
# CMAKE_MAP_IMPORTED_CONFIG_*: vcpkg config packages (CURL::libcurl) have no RelWithDebInfo -> would fall back to the debug DLL
$common = @('-G','Ninja','-DCMAKE_BUILD_TYPE=RelWithDebInfo','-DCMAKE_POLICY_VERSION_MINIMUM=3.5',
'-DCMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO=Release;RelWithDebInfo;',"-DCMAKE_INSTALL_PREFIX=$Pfx","-DCMAKE_PREFIX_PATH=$Dep")
function Build-Cmake($name, $src, [string[]]$opts) {
$bdir = "$B\$name"
Invoke-Step "$name configure" { cmake --fresh -S $src -B $bdir @common @opts } $Log
Invoke-Step "$name build" { cmake --build $bdir } $Log
Invoke-Step "$name install" { cmake --install $bdir } $Log
}
Build-Cmake 'apr' "$Root\src\apr-$($V.apr.version)" @('-DAPR_INSTALL_PRIVATE_H=ON','-DAPR_BUILD_TESTAPR=OFF')
Build-Cmake 'apr-util' "$Root\src\apr-util-$($V.'apr-util'.version)" @(
'-DAPU_HAVE_CRYPTO=ON','-DAPU_HAVE_ODBC=OFF','-DAPR_HAS_LDAP=OFF','-DAPR_BUILD_TESTAPR=OFF')
Build-Cmake 'httpd' "$Root\src\httpd-$($V.httpd.version)" @(
'-DENABLE_MODULES=i','-DINSTALL_MANUAL=OFF',
"-DPCRE_INCLUDE_DIR=$Dep/include","-DPCRE_LIBRARIES=$Dep/lib/pcre2-8.lib",'-DPCRE_CFLAGS=-DHAVE_PCRE2',
"-DNGHTTP2_INCLUDE_DIR=$Dep/include","-DNGHTTP2_LIBRARIES=$Dep/lib/nghttp2.lib",
"-DBROTLI_INCLUDE_DIR=$Dep/include","-DBROTLI_LIBRARIES=$Dep/lib/brotlienc.lib;$Dep/lib/brotlicommon.lib",
"-DCURL_INCLUDE_DIR=$Dep/include","-DCURL_LIBRARIES=$Dep/lib/libcurl.lib",
"-DJANSSON_INCLUDE_DIR=$Dep/include","-DJANSSON_LIBRARIES=$Dep/lib/jansson.lib")
# mod_lua is skipped automatically: httpd only looks for Lua 5.1 (not needed by the target production setup)
# cmake --install of httpd does not lay down conf/error/icons -> copy the runtime data files from the source tree
$hs = "$Root\src\httpd-$($V.httpd.version)\docs"
New-Item -ItemType Directory -Force "$Pfx\conf\extra", "$Pfx\conf\vhosts" | Out-Null
Copy-Item "$hs\conf\mime.types", "$hs\conf\magic", "$hs\conf\charset.conv" "$Pfx\conf" -Force
Copy-Item "$hs\error", "$hs\icons" $Pfx -Recurse -Force
Build-Cmake 'mod_fcgid' "$Root\src\mod_fcgid-$($V.mod_fcgid.version)\modules\fcgid" @()
# runtime DLLs of the third-party libs next to httpd.exe - only those actually imported (transitively)
$avail = @{}; Get-ChildItem "$Dep\bin\*.dll" | ForEach-Object { $avail[$_.Name.ToLower()] = $_.FullName }
$debugOnly = @{}; Get-ChildItem "$Dep\debug\bin\*.dll" -ErrorAction SilentlyContinue |
Where-Object { -not $avail.ContainsKey($_.Name.ToLower()) } | ForEach-Object { $debugOnly[$_.Name.ToLower()] = 1 }
$queue = [System.Collections.Generic.Queue[string]]::new()
Get-ChildItem "$Pfx\bin\*.exe", "$Pfx\bin\*.dll", "$Pfx\modules\*.so" | ForEach-Object { $queue.Enqueue($_.FullName) }
$need = @{}
while ($queue.Count) {
dumpbin /nologo /dependents $queue.Dequeue() | ForEach-Object {
$n = $_.Trim().ToLower()
if ($debugOnly.ContainsKey($n)) { throw "[ERR] release binary imports a debug DLL: $n" }
if ($avail.ContainsKey($n) -and -not $need.ContainsKey($n)) { $need[$n] = $avail[$n]; $queue.Enqueue($avail[$n]) }
}
}
$need.Values | Copy-Item -Destination "$Pfx\bin" -Force
# OpenSSL loads its providers at runtime (not an import) - keep the legacy provider for old PKCS#12 files
if (Test-Path "$Dep\bin\legacy.dll") { Copy-Item "$Dep\bin\legacy.dll" "$Pfx\bin" -Force }
Write-Host "[OK] runtime DLLs: $(($need.Keys | Sort-Object) -join ' ')"
& "$Pfx\bin\httpd.exe" -V *>> $Log
Write-Host "[OK] apache -> $Pfx"
& "$Pfx\bin\httpd.exe" -v
The opening section: variables and the MSVC environment
$Rootis the kit folder (the parent of thescriptsfolder), so you can run the script from anywhere.$Depand$Pfxreplace\with/because CMake treats backslashes as escape characters. Passing Windows paths directly would cause them to be misinterpreted.Remove-Item $Logdeletes the old log soapache.logcontains only the current run.Enter-VsEnv(inlib.ps1) loads the MSVC environment into the running pwsh session, as explained below.
Functions from lib.ps1 that the script calls
# Any VS 2022 edition with the C++ x64 tools (Build Tools here, Enterprise on GitHub runners)
function Get-VsPath {
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$p = & $vswhere -latest -products * -version '[17.0,18.0)' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
if (-not $p) { throw '[ERR] Visual Studio 2022 with C++ x64 tools not found (see https://linuxthai.org/article/apm-build-windows-1-prepare-tools)' }
return $p
}
$Script:VcVars = "$(Get-VsPath)\VC\Auxiliary\Build\vcvars64.bat"
function Enter-VsEnv {
if ($env:VSCMD_ARG_TGT_ARCH -eq 'x64') { return }
$vswhereDir = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer"
cmd /c "set `"PATH=$vswhereDir;%PATH%`" && `"$Script:VcVars`" >nul && set" | ForEach-Object {
if ($_ -match '^([^=]+)=(.*)$') { [Environment]::SetEnvironmentVariable($Matches[1], $Matches[2]) }
}
# never let XAMPP binaries leak into a build
$env:Path = ($env:Path -split ';' | Where-Object { $_ -and $_ -notmatch '(?i)xampp' }) -join ';'
if (-not (Get-Command cl.exe -ErrorAction SilentlyContinue)) { throw '[ERR] MSVC environment not loaded' }
}
function Invoke-Step([string]$Name, [scriptblock]$Block, [string]$Log) {
Write-Host "[..] $Name"
& $Block *>> $Log
if ($LASTEXITCODE) { Get-Content $Log -Tail 30; throw "[ERR] $Name failed rc=$LASTEXITCODE (log: $Log)" }
Write-Host "[OK] $Name"
}
Get-VsPath asks vswhere for the newest Visual Studio 2022 that has the C++ x64 tools and returns its install folder, and $Script:VcVars appends the path to vcvars64.bat. If nothing is found the script stops with [ERR] Visual Studio 2022 with C++ x64 tools not found and a link back to Part 1. (Kits downloaded before the afternoon of 22 Oct hardcoded the BuildTools path, so any other edition or install folder failed right at the start.)
Enter-VsEnv does what a Developer Prompt does: it runs vcvars64.bat in cmd (which sets PATH, INCLUDE and LIB so it can find cl.exe and dumpbin.exe), then prints all the variables with set and applies each line to the running pwsh session. If it still cannot find cl.exe, the script stops with [ERR] MSVC environment not loaded.
Invoke-Step wraps every long, noisy command. It prints [..] ชื่อขั้น on screen, appends all command output (every stream) to the log file, then checks the exit code. If it is not 0, it prints the last 30 log lines on screen and stops with [ERR] ชื่อขั้น failed rc=.... This keeps the screen clear, with only [..] and [OK] lines; the actual CMake and compiler output goes into C:\apm-build\build\logs\apache.log.
The script's actual execution order
- Set
$common(the options used for every component), then define theBuild-Cmakefunction, which runs the 3 steps: configure, build and install. - Build APR and install it into
stage\apache. - Build APR-util (using the APR just installed).
- Build httpd (using both APR and APR-util, plus the vcpkg libraries).
- Copy files that httpd's
cmake --installdoes not install:mime.types,magicandcharset.convintoconf\, and theerror\andicons\folders fromdocs\in the source tree, as described in the comments. - Build mod_fcgid from its source's
modules\fcgidsubfolder. - Find the required DLLs and copy them beside
httpd.exe(Section 6). - Print
httpd.exe -Vto the log andhttpd.exe -vto the screen.
4. Every CMake option and why it is set
Options used for every component ($common)
| option | What it does | Without it |
|---|---|---|
-G Ninja |
Use Ninja as the build tool | You get a slower build tool (CMake on Windows normally selects the Visual Studio generator) |
-DCMAKE_BUILD_TYPE=RelWithDebInfo |
Enable full optimization and generate .pdb files |
Release provides no .pdb files for debugging Apache crashes; Debug is slow and requires debug DLLs |
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 |
Override CMake 4.x's compatibility restriction | APR, httpd and fcgid declare cmake_minimum_required versions below 3.5, so CMake 4.x stops with Compatibility with CMake < 3.5 has been removed |
-DCMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO=Release;RelWithDebInfo; |
Use Release if a vcpkg package has no configuration named RelWithDebInfo | mod_md links to libcurl-d.dll (the debug version), because vcpkg's CURL::libcurl target has no RelWithDebInfo configuration |
-DCMAKE_INSTALL_PREFIX=C:/apm-build/stage/apache |
Set the destination for cmake --install |
It installs into C:\Program Files |
-DCMAKE_PREFIX_PATH=C:/apm-build/build/vcpkg_installed/x64-windows |
Tell CMake to search for libraries here first | It cannot find OpenSSL, zlib or libxml2, or finds other copies on the machine |
Besides these options, the script calls cmake with --fresh every time, deleting the old CMakeCache.txt before configuring again. This is necessary because stale cached values can produce incorrect results (I encountered this when testing a module setting of N and removing it later).
APR 1.7.6
| option | What it does |
|---|---|
-DAPR_INSTALL_PRIVATE_H=ON |
Install APR's internal (private) headers, which httpd needs during its build; the log shows Install private .h for httpd .... : ON |
-DAPR_BUILD_TESTAPR=OFF |
Skip building the test suite to save time; the log shows Build test suite ... : OFF |
APR-util 1.6.5
| option | What it does |
|---|---|
-DAPU_HAVE_CRYPTO=ON |
Enable the cryptography module that uses OpenSSL, producing apr_crypto_openssl-1.dll |
-DAPU_HAVE_ODBC=OFF |
Omit the ODBC driver |
-DAPR_HAS_LDAP=OFF |
Omit LDAP, matching the production machine, which does not use mod_ldap |
-DAPR_BUILD_TESTAPR=OFF |
Skip building the test suite |
When configuring this component, the log shows that CMake found OpenSSL and expat in vcpkg, which confirms that CMAKE_PREFIX_PATH is working.
-- Found OpenSSL: C:/apm-build/build/vcpkg_installed/x64-windows/lib/libcrypto.lib (found version "3.5.9")
-- Found EXPAT: C:/apm-build/build/vcpkg_installed/x64-windows/lib/libexpat.lib (found version "2.9.0")
httpd 2.4.69
| option | What it does |
|---|---|
-DENABLE_MODULES=i |
Request every module whose prerequisites are available; CMake skips any with missing dependencies and explains why in the log (see the list below). httpd accepts only O, i, I, a and A; setting N causes an error |
-DINSTALL_MANUAL=OFF |
Skip installing Apache's HTML manual (large and unnecessary) |
-DPCRE_INCLUDE_DIR, -DPCRE_LIBRARIES |
Point to pcre2-8.lib in vcpkg for regex support |
-DPCRE_CFLAGS=-DHAVE_PCRE2 |
Tell the code that the supplied library is PCRE2 (the newer version), rather than PCRE1 |
-DNGHTTP2_INCLUDE_DIR, -DNGHTTP2_LIBRARIES |
Point to nghttp2.lib for mod_http2 |
-DBROTLI_INCLUDE_DIR, -DBROTLI_LIBRARIES |
Point to brotlienc.lib and brotlicommon.lib (two files separated by ; in a single option) for mod_brotli |
-DCURL_INCLUDE_DIR, -DCURL_LIBRARIES |
Point to libcurl.lib for mod_md |
-DJANSSON_INCLUDE_DIR, -DJANSSON_LIBRARIES |
Point to jansson.lib for mod_md |
For libraries whose paths do not need to be set manually (OpenSSL, zlib and libxml2), CMake finds them through CMAKE_PREFIX_PATH. The httpd log shows:
-- Found LibXml2: C:/apm-build/build/vcpkg_installed/x64-windows/lib/libxml2.lib (found version "2.15.4")
-- Found OpenSSL: C:/apm-build/build/vcpkg_installed/x64-windows/lib/libcrypto.lib (found version "3.5.9")
-- Found ZLIB: C:/apm-build/build/vcpkg_installed/x64-windows/lib/z.lib (found version "1.3.2")
-- Found CURL: C:/apm-build/build/vcpkg_installed/x64-windows/share/curl/CURLConfig.cmake (found version "8.23.0")
The summary CMake prints at the end of httpd configuration shows what it detected and which modules it skipped.
-- Summary of feature detection:
--
-- LIBXML2_FOUND ............ : TRUE
-- LUA51_FOUND .............. : FALSE
-- NGHTTP2_FOUND ............ : TRUE
-- OPENSSL_FOUND ............ : TRUE
-- ZLIB_FOUND ............... : TRUE
-- BROTLI_FOUND ............. : TRUE
-- CURL_FOUND ............... : TRUE
-- JANSSON_FOUND ............ : TRUE
-- APR_HAS_LDAP ............. : FALSE
-- APR_HAS_XLATE ............ : FALSE
-- APU_HAVE_CRYPTO .......... : TRUE
...
-- Modules not built:
-- mod_authnz_ldap
-- mod_socache_dc
-- mod_charset_lite
-- mod_ldap
-- mod_lua
The 5 modules that were not built are not a problem, because the production machine uses none of them. mod_ldap and mod_authnz_ldap lack LDAP, mod_charset_lite lacks APR's XLATE support, mod_socache_dc requires an unavailable library, and httpd only searches for Lua 5.1 for mod_lua, which is not in this setup. This matches the log message: mod_lua was requested but couldn't be built due to a missing prerequisite (LUA51_FOUND).
mod_fcgid 2.3.9
There are no additional options (@()); it uses only $common. It is a small CMake project with 13 steps, producing a single file, mod_fcgid.so. I always build it after httpd because it needs the APR and httpd headers and .lib files just installed into stage\apache.
5. Run the build step by step
Step 5.1 Start the script
Where: pwsh (regular user), in C:\apm-build
pwsh -NoProfile -ExecutionPolicy Bypass -File scripts\20-apache.ps1
Step 5.2 Watch the screen while waiting
The first run takes about 4 minutes on my machine (i5-12500H). The screen progresses through pairs of [..] and [OK], one step at a time, for a total of 12 pairs (4 components times configure, build and install). If it stays at [..] httpd build for a minute or more, that is normal: this is the largest step (523 Ninja steps).
[..] apr configure
[OK] apr configure
[..] apr build
[OK] apr build
[..] apr install
[OK] apr install
[..] apr-util configure
[OK] apr-util configure
[..] apr-util build
[OK] apr-util build
[..] apr-util install
[OK] apr-util install
[..] httpd configure
[OK] httpd configure
[..] httpd build
[OK] httpd build
...
The time varies considerably between steps. For example, APR configuration takes 1.6 seconds, while httpd configuration takes 5.1 seconds (figures from the log). The build steps account for nearly all the time. The number of Ninja steps is: APR 164, APR-util 94, httpd 523, mod_fcgid 13.
Step 5.3 Watch live progress numbers in the log (optional)
To see how far it has progressed, open a second pwsh window and follow the end of the log.
Get-Content C:\apm-build\build\logs\apache.log -Tail 3 -Wait
You will see lines such as [124/523] Linking C shared library libhttpd.dll. The first number is the completed step count; the second is the total. Press Ctrl+C to stop watching (this does not affect the build).
Step 5.4 Check the final lines when it finishes
You should see these three lines at the end of the script. The DLL list is sorted alphabetically (I have omitted half of the following output from httpd.exe -v; see the full output in Step 7.1).
[OK] runtime DLLs: brotlicommon.dll brotlienc.dll iconv-2.dll jansson.dll libcrypto-3-x64.dll libcurl.dll libexpat.dll libssl-3-x64.dll libxml2.dll nghttp2.dll pcre2-8.dll z.dll
[OK] apache -> C:/apm-build/stage/apache
Server version: Apache/2.4.69 (Win64)
Server built: Oct 22 2026 12:19:57
The 12 copied DLLs are listed here (the runtime DLLs line is sorted by the script's code; I checked the names against the files in stage\apache\bin on my machine, and all 12 are present). The Server built line will show your own build time.
Step 5.5 Can you run it again?
Yes, just run it again. Ninja builds only what has changed, and --fresh only reruns configuration. I measured 1.6 minutes for a run with the cache already populated (most of that time went into reconfiguring all four components and checking DLLs).
6. Use dumpbin to find the DLLs needed beside httpd.exe
The problem on Windows is that when httpd.exe loads mod_ssl.so, it needs libssl-3-x64.dll, which Windows searches for in order, starting with the executable's folder. If it cannot find it, the required module will not load and Apache will not start. That means the vcpkg DLLs must go in stage\apache\bin, beside httpd.exe. The question is which ones.
The easiest approach is to copy all 18 DLLs from vcpkg's bin folder. I do not do that, because it includes files nothing uses and leaves me unable to say which ones are required. Instead, the script uses dumpbin /dependents, an MSVC tool that lists the DLLs a file imports.
Try running dumpbin manually first
Where: regular pwsh, outside a Developer Prompt. dumpbin.exe is not on a regular pwsh session's PATH, so first find its full path (Step 6.1). The script does not need this because Enter-VsEnv already handles it.
Step 6.1 Find dumpbin.exe
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -version '[17.0,18.0)' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
$dumpbin = (Get-ChildItem "$vs\VC\Tools\MSVC\*\bin\Hostx64\x64\dumpbin.exe" | Select-Object -Last 1).FullName
$dumpbin
C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\14.44.35207\bin\Hostx64\x64\dumpbin.exe
Your version number may differ from 14.44.35207.
Step 6.2 Inspect httpd.exe's imports
& $dumpbin /nologo /dependents C:\apm-build\stage\apache\bin\httpd.exe
Dump of file C:\apm-build\stage\apache\bin\httpd.exe
File Type: EXECUTABLE IMAGE
Image has the following dependencies:
libhttpd.dll
libapr-1.dll
libaprutil-1.dll
VCRUNTIME140.dll
api-ms-win-crt-runtime-l1-1-0.dll
api-ms-win-crt-stdio-l1-1-0.dll
...
KERNEL32.dll
I have omitted the api-ms-win-crt-* lines and the summary table at the end. httpd.exe itself uses only the first three DLLs (built locally); the rest are Windows and MSVC runtime DLLs already present on the machine.
Step 6.3 Inspect the most complex module's imports
& $dumpbin /nologo /dependents C:\apm-build\stage\apache\modules\mod_md.so
Image has the following dependencies:
libcrypto-3-x64.dll
libcurl.dll
jansson.dll
libhttpd.dll
libapr-1.dll
libaprutil-1.dll
VCRUNTIME140.dll
api-ms-win-crt-string-l1-1-0.dll
...
KERNEL32.dll
mod_md.so needs libcrypto-3-x64.dll, libcurl.dll and jansson.dll from vcpkg. libcurl.dll itself imports more DLLs (the next step), which is why the script must follow the dependencies recursively.
Step 6.4 See what libcurl.dll pulls in
& $dumpbin /nologo /dependents C:\apm-build\stage\apache\bin\libcurl.dll | Select-String '\.dll'
If z.dll is listed, libcurl.dll depends on the zlib built by vcpkg. I compared the names with those in vcpkg_installed\x64-windows\bin and found that libcurl pulls in z.dll, libxml2 pulls in z.dll and iconv-2.dll, brotlienc pulls in brotlicommon, and libssl-3-x64 pulls in libcrypto-3-x64.
The script automates the same process
The end of the script does the following, in order.
$availis a registry of all DLL names (in lowercase) invcpkg_installed\x64-windows\bin, along with their full paths. It answers the question, "Does this DLL name belong to vcpkg?" Windows DLLs such asKERNEL32.dllare not in the registry, so they are skipped automatically.$debugOnlycontains DLL names found only in vcpkg'sdebug\binfolder and absent frombin. On my machine, these includejansson_d.dll,libcurl-d.dll,libexpatd.dll,pcre2-8d.dll,zd.dlland a few others. This is the forbidden list.$queueis the queue of files to scan. It starts with every.exeand.dllinstage\apache\binand every.soinstage\apache\modules.- Loop until the queue is empty: take one file, run
dumpbin /dependents, then examine each output line (trim whitespace and convert to lowercase).- If the name is in
$debugOnly, stop the script immediately with[ERR] release binary imports a debug DLL: <ชื่อ>. - If the name is in
$availand has not been recorded yet, add it to$needand put that DLL into the queue too, so it will be scanned for further dependencies (this is how it findsz.dllthroughlibcurl.dll). - Other names (header lines, the summary table and Windows DLLs) match neither condition and are skipped.
- If the name is in
- After the loop, copy everything in
$needintostage\apache\bin. - Explicitly copy one additional file,
legacy.dll, because OpenSSL 3 loads providers by name at runtime rather than importing them directly, so dumpbin cannot see it. This one is used to read older PKCS#12 files.
The result is 12 DLLs actually imported, plus 1 additional legacy.dll, out of the 18 in vcpkg. The remaining 5 are unused and are not copied (brotlidec, charset-1, pcre2-16, pcre2-32, pcre2-posix). You can check the count directly.
(Get-ChildItem C:\apm-build\stage\apache\bin\*.dll).Count
17
The 17 DLLs consist of 4 built locally (libapr-1, libaprutil-1, apr_crypto_openssl-1, libhttpd), plus the 12 found by tracing dependencies, plus legacy.dll.
The guard against linking debug DLLs
The issue described at the beginning happened because vcpkg builds two sets: bin\ (release) and debug\bin\ (debug). The debug files have different names, such as libcurl-d.dll and zd.dll. If CMake chooses the wrong configuration, the .so files built with RelWithDebInfo reference those names, and Windows cannot find them on another machine. But on the build machine, if your PATH happens to point into vcpkg, it may find them without you noticing. That is why I do not trust a test run on the same machine and have the script check the imported names directly.
The -DCMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO=Release;RelWithDebInfo; option in Section 4 fixes the root cause, and this guard checks that the fix still works. If a future vcpkg update breaks it again, the script will stop immediately and name the offending DLL.
7. Check the results one by one
Step 7.1 Check the httpd version
Where: regular pwsh, in any folder. Add 2>&1 because httpd writes its version information to the error stream.
C:\apm-build\stage\apache\bin\httpd.exe -V 2>&1 | Select-String "Server (version|loaded|MPM)"
Server version: Apache/2.4.69 (Win64)
Server loaded: APR 1.7.6, APR-UTIL 1.6.5, PCRE 10.49 2026-09-28
Server MPM: WinNT
These three lines tell you everything: 64-bit httpd 2.4.69, the APR/APR-util versions built locally, PCRE version 10.49 (PCRE2 from vcpkg), and the WinNT MPM, which is the only MPM available on Windows.
Step 7.2 Count the modules
(Get-ChildItem C:\apm-build\stage\apache\modules\*.so).Count
119
I counted 119 on the machine in my latest measurement (my older notes say 120). Either 119 or 120 is normal. More important than the count is having every module the config uses. The next step checks that.
Step 7.3 Check that every module referenced by the template has a file
The templates\httpd.conf file contains 41 LoadModule lines. This command reads every line and reports only .so files missing from stage.
Select-String -Path C:\apm-build\templates\httpd.conf -Pattern '^LoadModule \w+ (modules/\S+)' |
ForEach-Object { $_.Matches[0].Groups[1].Value } |
Where-Object { -not (Test-Path "C:\apm-build\stage\apache\$_") }
If everything is present, nothing will be printed. If a filename appears, that module was not built; open the "Modules not built" list in apache.log.
Step 7.4 Create the folders needed by the test config
httpd -t checks that folders referenced by the config exist, and stage does not yet contain those folders (deploy.cmd in Part 7 creates them under C:\APM). I therefore create them temporarily in stage.
$S = 'C:\apm-build\stage'
New-Item -ItemType Directory -Force "$S\www\default","$S\logs\apache","$S\tmp" | Out-Null
Step 7.5 Create a temporary dummy php-cgi.exe file
The config contains this line (cut from templates\httpd.conf):
# inner \" quotes: mod_fcgid splits the wrapper command line on spaces (apr_tokenize_to_argv), so a path
# like "D:/APM test/php/php-cgi.exe" must be quoted inside the directive argument as well
FcgidWrapper "\"${APM}/php/php-cgi.exe\"" .php
Note the double quoting. The outer quotes belong to Apache and wrap the argument; the inner ones, escaped as \", are passed on to mod_fcgid. The reason is that mod_fcgid uses the value as a command to run and splits it on spaces (apr_tokenize_to_argv). If the install path contains a space, for example D:/APM test/php/php-cgi.exe, it reads only D:/APM as the program and Apache complains Wrapper D:/APM cannot be accessed (the path in that message is just an example). The template from before the afternoon of 22 Oct had no inner quotes and only worked for paths without spaces. That is fixed now.
mod_fcgid checks that the file exists when reading the config. But PHP has not been built yet (Part 6), so I put an empty file with the same name there to pass the check.
New-Item -ItemType Directory -Force "$S\php" | Out-Null
New-Item -ItemType File "$S\php\php-cgi.exe" | Out-Null
I tried removing it and running -t, and did get an error (see the "If something breaks here" table). If you have already built PHP and the real stage\php\php-cgi.exe exists, skip this step and do not overwrite it.
Step 7.6 Create a test config from the template
The template has {{APM_ROOT}}, {{HTTP_PORT}} and {{BIND}} placeholders ({{BIND}} is the address Apache listens on; the real line is Listen {{BIND}}:{{HTTP_PORT}}, normally 127.0.0.1, meaning this machine only; Part 7 has a -Lan switch that makes it 0.0.0.0), which 90-deploy.ps1 fills during actual deployment. Here I fill them manually with -replace (using an arbitrary port 8099, because -t does not actually open a port, and BIND set to 127.0.0.1) and remove the Include conf/extra/apm-ssl.conf line, because the TLS file and certificate will not exist until deployment.
$conf = (Get-Content C:\apm-build\templates\httpd.conf -Raw) `
-replace '\{\{APM_ROOT\}\}', ($S -replace '\\','/') `
-replace '\{\{HTTP_PORT\}\}', '8099' `
-replace '\{\{BIND\}\}', '127.0.0.1' `
-replace 'Include conf/extra/apm-ssl.conf', ''
Set-Content "$S\apache\conf\apm-conftest.conf" $conf -Encoding ascii
Step 7.7 Check the config
C:\apm-build\stage\apache\bin\httpd.exe -t -f C:\apm-build\stage\apache\conf\apm-conftest.conf
Syntax OK
-t reads the entire config and actually loads every module in LoadModule, but does not open a port or start the server. So Syntax OK confirms two things at once: the config is syntactically valid, and every .so and its DLL dependencies can load. That is stronger evidence than visually checking a list of DLLs.
Step 7.8 List the modules actually loaded (optional)
C:\apm-build\stage\apache\bin\httpd.exe -M -f C:\apm-build\stage\apache\conf\apm-conftest.conf 2>&1 | Select-Object -First 8
Loaded Modules:
core_module (static)
win32_module (static)
mpm_winnt_module (static)
http_module (static)
so_module (static)
access_compat_module (shared)
actions_module (shared)
On my machine, there are 46 modules in total: 5 embedded in httpd.exe (static), plus 41 from LoadModule, matching the count of 41 in Step 7.3.
Step 7.9 Clean up the test files
Delete the test config and dummy file so they do not get mixed in with the real files during deployment. You can leave www, logs and tmp in stage, because deploy.cmd copies only stage\apache, stage\php and stage\mariadb.
Remove-Item C:\apm-build\stage\apache\conf\apm-conftest.conf
Remove-Item C:\apm-build\stage\php\php-cgi.exe
Skip the second line if you already have the real PHP file (Step 7.5). The empty stage\php folder will be deleted before the build in Part 6.
The resulting stage\apache layout
8. Learn to read apache.log before you need it
This single log file is very long (my apache.log has 1,687 lines and is about 130 KB). Reading from the first line will leave you lost. I use these three commands in order when something goes wrong. Run them all in regular pwsh.
Step 8.1 Find the first error
The first error is the root cause; later errors are often consequences. MSVC compiler errors use error C followed by a numeric code, while Ninja puts FAILED: before the command that failed.
Select-String -Path C:\apm-build\build\logs\apache.log -Pattern 'error C\d+|FAILED:|CMake Error' | Select-Object -First 5
If the entire build succeeded, this command prints nothing.
Step 8.2 Check what CMake says it skipped
Lines containing was requested but couldn't be built identify modules skipped because of missing dependencies. Check that there are only the 5 known modules.
Select-String -Path C:\apm-build\build\logs\apache.log -Pattern "couldn't be built" | ForEach-Object Line
-- mod_authnz_ldap was requested but couldn't be built due to a missing prerequisite (APR_HAS_LDAP)
-- mod_socache_dc was requested but couldn't be built due to a missing prerequisite (AN_UNIMPLEMENTED_SUPPORT_LIBRARY_REQUIREMENT)
-- mod_charset_lite was requested but couldn't be built due to a missing prerequisite (APR_HAS_XLATE)
-- mod_ldap was requested but couldn't be built due to a missing prerequisite (APR_HAS_LDAP)
-- mod_lua was requested but couldn't be built due to a missing prerequisite (LUA51_FOUND)
If another module appears in this list, such as mod_ssl or mod_http2, its vcpkg library is missing. Go back to Part 2.
Step 8.3 See which step takes time
Ninja's [n/total] numbers show how many steps each component has. To see your own numbers, look at the final line showing each component's total.
Select-String -Path C:\apm-build\build\logs\apache.log -Pattern '^\[1/\d+\]' | ForEach-Object { ($_.Line -split ' ')[0] }
My results are [1/164] for APR, [1/94] for APR-util, [1/523] for httpd and [1/13] for mod_fcgid. These are the source of the numbers in the statgrid at the start of the article.
Why keep .pdb files?
I selected RelWithDebInfo from the start so there would be a .pdb file beside every .exe, .dll and .so. These files store function names and source line numbers, do not slow down the program, and are not loaded at runtime. But if httpd.exe crashes one day, you can open the crash dump in WinDbg or Visual Studio and see actual function names instead of numeric addresses. During deployment (Part 7), the script does not copy .pdb files into C:\APM, to reduce the size. The complete set remains in stage\; use the files there if you need to debug.
Why choose mod_fcgid over the alternatives?
On Linux, the production machine runs PHP through php-fpm, which is unavailable on Windows. The closest stable option on Windows is mod_fcgid, which runs php-cgi.exe as a group of processes, so that is what I chose. The configuration details are in templates\httpd.conf (the FcgidWrapper, FcgidMaxProcesses 32 and related lines), which I will return to in Parts 6 and 7 when testing with the real PHP build.
9. If something breaks here
| Symptom | Cause | Fix |
|---|---|---|
[ERR] Visual Studio 2022 with C++ x64 tools not found (see https://linuxthai.org/article/apm-build-windows-1-prepare-tools) |
vswhere cannot find a Visual Studio 2022 with the C++ x64 component (not installed, installed without "MSVC v143 - VS 2022 C++ x64/x86 build tools", or it is Visual Studio 2019 or 2026, which the script filters out) |
Run the command from Step 1.5. If it prints nothing, open the Visual Studio Installer and tick the C++ workload as in Part 1. You do not need to edit any script, and any install path works |
[ERR] MSVC environment not loaded |
Visual Studio was found, but cl.exe still does not appear after running vcvars64.bat (half-finished install or damaged C++ component) |
Press Repair in the Visual Studio Installer, then run the script again |
cmake or ninja is not recognized |
PATH has not been updated since installation | Close pwsh and open it again, or go back to Part 1 |
CMake does not recognize the --fresh option |
CMake is older than 3.24 | Update CMake (winget upgrade Kitware.CMake) |
Compatibility with CMake < 3.5 has been removed |
Running cmake manually without -DCMAKE_POLICY_VERSION_MINIMUM=3.5 |
Use the script instead, or add that option (CMake 4.x rejects the old CMake files in APR/httpd/fcgid) |
Unexpected enablement value "N" for mod_lua |
Setting -DENABLE_LUA=N (httpd accepts only O, i, I, a, A), or a stale cached value |
Remove that option and rerun the script; --fresh clears the cache |
[ERR] release binary imports a debug DLL: libcurl-d.dll (or zd.dll, etc.) |
A .so links to vcpkg's debug libraries |
Check that CMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO is still in the script's $common; if you just edited the file, delete build\httpd and rerun |
mod_lua is built with Lua 5.5 |
Adding lua to deps\apache\vcpkg.json causes FindLua51 to pick up an incompatible newer Lua version |
Remove lua from the manifest and rerun 10-deps-vcpkg.ps1 (vcpkg removes it automatically) |
[ERR] httpd build failed rc=1 (log: ...) followed by the last 30 lines |
A compilation error somewhere | Open apache.log and find the first error C or FAILED: (rather than warning); that line identifies the file and line that failed |
| OpenSSL / zlib / libxml2 cannot be found during configuration | Incomplete vcpkg libraries, or CMAKE_PREFIX_PATH points to the wrong location |
Check Step 1.3 and rerun 10-deps-vcpkg.ps1 to completion |
httpd -t reports Wrapper ...php-cgi.exe cannot be accessed: (720003)The system cannot find the path specified. |
stage\php\php-cgi.exe does not exist yet |
Follow Step 7.5 (I got this exact message without the dummy file) |
Apache reports Wrapper D:/APM cannot be accessed (the path in your message may differ) |
The install path contains a space and FcgidWrapper has no inner quotes (mod_fcgid splits the command on spaces) |
Use the new templates\httpd.conf, which has FcgidWrapper "\"${APM}/php/php-cgi.exe\"" .php. If you already deployed, edit the same line in the httpd.conf under C:\APM, or download the new kit and run deploy.cmd -ResetConfig |
httpd -t reports DocumentRoot '.../stage/www/default' is not a directory, or is not readable |
The folder has not been created | Follow Step 7.4 |
httpd -t reports AH02291: Cannot access directory '.../stage/logs/apache/' for main error log |
The logs\apache folder is missing |
Follow Step 7.4 |
httpd -t reports that mime.types cannot be found |
The copy step from docs\conf did not run (the script failed before reaching it) |
Run the entire 20-apache.ps1 script to completion, rather than only half of it |
Windows reports "Cannot find <name>.dll" when running httpd.exe |
The DLL is not beside httpd.exe |
Check that the script finishes with [OK] runtime DLLs: ... and that all files from Step 6 are present |
apache.log contains the prompt specify a file name or directory name on the target (F = file, D = directory)? |
A copy command in httpd's cmake --install asks a question during execution (with nobody to answer) |
No action is needed; my build log contains this message twice, and every step still finishes with [OK] |
The log contains warning C4996 (stricmp, getenv) and warning C4244 |
Warnings from Apache's and mod_fcgid's own code | This is not a problem; the build completes normally |
| Unusually slow (tens of minutes) | Defender scans newly created .obj files |
Set exclusions as described in Part 1 |
One final point: if the script fails midway, fix the cause and rerun it from the beginning without deleting anything. --fresh clears CMake's cache, and Ninja skips files already compiled.
10. Checklist before moving to Part 4
- The
20-apache.ps1script finishes with[OK] apache -> C:/apm-build/stage/apacheandServer version: Apache/2.4.69 (Win64) httpd.exe -VreportsAPR 1.7.6, APR-UTIL 1.6.5, PCRE 10.49andServer MPM: WinNT(Step 7.1)mod_fcgid.soandmod_ssl.soexist instage\apache\modules, with around 119-120.sofiles in total (Step 7.2)- The
LoadModulecheck in Step 7.3 prints nothing httpd.exe -twith the test config returnsSyntax OK(Step 7.7)- There are 17 DLLs in
stage\apache\bin, with no vcpkg files ending in-d.dllord.dllmixed in - The test file
apm-conftest.confhas been deleted (Step 7.9)
If you just want to use APM without compiling anything, the finished build is at github.com/ChonLaWan/APM-Server. Download APM-1.0.0-win64.zip (about 140 MB) from Releases, compare its sha256 with the attached .sha256 file, unzip it anywhere and run manager\APMManager.exe. The Manager picks free ports, writes the config, issues the certificate and creates the database for you. Its Check for updates button downloads a new release, verifies the sha256 and swaps the binaries without touching your config, databases or sites.
Contents · Previous: Part 2: Fetch source and libraries with vcpkg · Next: Part 4: Build MariaDB
At this point, you have a complete Apache build compiled yourself. It has not been started yet, and there is no actual httpd.conf; deploy.cmd in Part 7 will create that along with a certificate for HTTPS. In Part 4, I will build the MariaDB database, the longest step after vcpkg. I will come back and update this later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first