EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Web / Build APM on Windows, Part 3: Apache httpd 2.4.69 with CMake
Web — Build APM บน Windows

Build APM on Windows, Part 3: Apache httpd 2.4.69 with CMake

by Ton-Or ·22 Oct 2026·14 min read
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.

4 minutesfirst build
794total ninja steps
119.so module files
150 MBstage\apache

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.

  1. 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.
  2. 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.
  3. httpd 2.4.69, the web server and the modules included in its source (ssl, http2, rewrite, proxy, etc.).
  4. 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).
Build order (each installs to stage\apache before the next uses it)APR 1.7.6164 ninja stepslibapr-1.dllAPR-util 1.6.594 stepslibaprutil-1.dllhttpd 2.4.69523 stepshttpd + 119 modulesmod_fcgid 2.3.913 stepsmod_fcgid.sobuild\vcpkg_installed\x64-windowsopenssl pcre2 nghttp2 brotli curl etc.stage\apache\ All output (about 150 MB)The script then uses dumpbin to copy imported vcpkg DLLs into bin\
APR-util and httpd use libraries from vcpkg. All four components install into the same destination folder, so later components can find the headers and .lib files from earlier ones.

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.

Why not use a packaged Apache build?

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

  • $Root is the kit folder (the parent of the scripts folder), so you can run the script from anywhere.
  • $Dep and $Pfx replace \ with / because CMake treats backslashes as escape characters. Passing Windows paths directly would cause them to be misinterpreted.
  • Remove-Item $Log deletes the old log so apache.log contains only the current run.
  • Enter-VsEnv (in lib.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

  1. Set $common (the options used for every component), then define the Build-Cmake function, which runs the 3 steps: configure, build and install.
  2. Build APR and install it into stage\apache.
  3. Build APR-util (using the APR just installed).
  4. Build httpd (using both APR and APR-util, plus the vcpkg libraries).
  5. Copy files that httpd's cmake --install does not install: mime.types, magic and charset.conv into conf\, and the error\ and icons\ folders from docs\ in the source tree, as described in the comments.
  6. Build mod_fcgid from its source's modules\fcgid subfolder.
  7. Find the required DLLs and copy them beside httpd.exe (Section 6).
  8. Print httpd.exe -V to the log and httpd.exe -v to 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.

Files scannedvcpkg DLLs found (pass 1)More found (pass 2)libhttpd.dllpcre2-8.dllmod_ssl.solibssl-3-x64.dlllibcrypto-3-x64.dllmod_md.so+ jansson.dll, libcryptolibcurl.dllz.dllmod_http2.songhttp2.dllmod_brotli.sobrotlienc.dllbrotlicommon.dllmod_xml2enc.solibxml2.dlliconv-2.dllTotal: 12 imported DLLs + legacy.dll copied into bin\Of 18 in vcpkg\bin, 5 are unused and not copied
Simplified to show the principle; libaprutil also imports libexpat. Every time the script finds a vcpkg DLL, it records it and scans it next.

The script automates the same process

The end of the script does the following, in order.

  1. $avail is a registry of all DLL names (in lowercase) in vcpkg_installed\x64-windows\bin, along with their full paths. It answers the question, "Does this DLL name belong to vcpkg?" Windows DLLs such as KERNEL32.dll are not in the registry, so they are skipped automatically.
  2. $debugOnly contains DLL names found only in vcpkg's debug\bin folder and absent from bin. On my machine, these include jansson_d.dll, libcurl-d.dll, libexpatd.dll, pcre2-8d.dll, zd.dll and a few others. This is the forbidden list.
  3. $queue is the queue of files to scan. It starts with every .exe and .dll in stage\apache\bin and every .so in stage\apache\modules.
  4. 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 $avail and has not been recorded yet, add it to $need and put that DLL into the queue too, so it will be scanned for further dependencies (this is how it finds z.dll through libcurl.dll).
    • Other names (header lines, the summary table and Windows DLLs) match neither condition and are skipped.
  5. After the loop, copy everything in $need into stage\apache\bin.
  6. 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

stage\apache\ after 20-apache.ps1 (about 150 MB)bin\httpd.exe, libhttpd.dll, htpasswd.exe etc. + 13 DLLs + .pdbmodules\mod_ssl.so mod_http2.so mod_fcgid.so ... 119 files + .pdbconf\mime.types magic charset.conv + extra\ vhosts\ (empty)error\ icons\Copied from source docs\; cmake --install omits theminclude\ lib\APR/httpd headers and .lib for builds; not deployedcgi-bin\ logs\Empty folders created by cmake --installNo httpd.conf yet; Part 7 (deploy) creates it from templates\httpd.conf
The contents of each stage\apache folder; the actual httpd.conf is still missing.

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

  1. The 20-apache.ps1 script finishes with [OK] apache -> C:/apm-build/stage/apache and Server version: Apache/2.4.69 (Win64)
  2. httpd.exe -V reports APR 1.7.6, APR-UTIL 1.6.5, PCRE 10.49 and Server MPM: WinNT (Step 7.1)
  3. mod_fcgid.so and mod_ssl.so exist in stage\apache\modules, with around 119-120 .so files in total (Step 7.2)
  4. The LoadModule check in Step 7.3 prints nothing
  5. httpd.exe -t with the test config returns Syntax OK (Step 7.7)
  6. There are 17 DLLs in stage\apache\bin, with no vcpkg files ending in -d.dll or d.dll mixed in
  7. The test file apm-conftest.conf has been deleted (Step 7.9)
Don't want to build it yourself

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.

Build APM on Windows series

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.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first