EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Web / Build APM on Windows, Part 2: Fetch Source and Libs with vcpkg
Web — Build APM บน Windows

Build APM on Windows, Part 2: Fetch Source and Libs with vcpkg

by Ton-Or ·22 Oct 2026·14 min read
Build APM on Windows, Part 2: Fetch Source and Libs with vcpkg

Before compiling anything, you need source files checked for tampering and every lib Apache requires. These two jobs take almost an hour combined on the first run, but you only do them once. On subsequent runs, almost everything comes from the cache.

I needed to write these instructions so someone who had never compiled anything could follow them, so I opened both scripts and read them again line by line, then checked every command in this article against the machine where I had already built the stack. Part 2 is the one I want you to read most slowly, because it determines where the material compiled into your web server comes from and how much you can trust it. If the downloaded source has been tampered with, even if you follow every line of the later steps correctly, the result is Apache with something extra inside that you do not know about.

This part covers two scripts: 00-fetch.ps1 downloads and verifies the hashes of all source archives and tools, and 10-deps-vcpkg.ps1 has vcpkg compile the libs Apache needs (OpenSSL, PCRE2, zlib, nghttp2, brotli, expat, libxml2, jansson, curl) from source using MSVC. If you have not read Part 1, installed Build Tools, and extracted the kit to C:\apm-build, go back and do that first, because this part assumes all the tools are ready.

Why I wrote this series

PHP released patches for two critical RCE vulnerabilities on 2026-10-21 (details are in the php2shell news article), so I built my own stack so I could change the version numbers in a single versions.json file and rebuild on the same day upstream releases an update. Part 2 is what makes that possible, because every source archive has its hash checked before use.

13files downloaded
560 MBtotal file size
3-5 minutesfirst fetch
45 minutesfirst vcpkg run

1. Preparation: Check That Part 1 Is Complete

Every step in this part and the following parts runs in PowerShell 7 (pwsh) as a regular user, without opening it as Administrator. The build scripts load the MSVC environment themselves, so you do not need to open a Developer Prompt. Section 1 is a check before starting; work through each step in order.

Step 1.1 Open pwsh

Where: Click Start, type pwsh, and open regular PowerShell 7 (not Run as administrator). The window title should say PowerShell 7, not Windows PowerShell.

$PSVersionTable.PSVersion.ToString()

You should see version 7.5 or later (my machine reports 7.6.7). If you get 5.1, you opened the wrong one. The 00-fetch.ps1 script uses ConvertFrom-Json -AsHashtable, which is available only in PowerShell 6 and later; running it in 5.1 will fail immediately.

Step 1.2 Go to the kit folder

Set-Location C:\apm-build
Get-ChildItem -Name

You should see this listing (the order may differ slightly).

deps
ports-overlay
scripts
templates
tools
.gitignore
build.cmd
deploy.cmd
README.txt
versions.json
versions.lock.json

If you do not see build.cmd here, the zip was extracted into a nested folder (C:\apm-build\apm-build-kit\build.cmd). Move everything up one level as described in Part 1 before continuing.

Step 1.3 Check that the main tools respond

git --version; cmake --version | Select-Object -First 1; ninja --version; pwsh --version
git version 2.54.0.windows.1
cmake version 4.1.2
1.13.1
PowerShell 7.6.1

Your versions may be newer than these; all that matters is that every tool responds. If any of them reports is not recognized, open a new pwsh window (winget has just added entries to PATH), and if it still does not work, go back to Part 1.

Step 1.4 Check disk space

[math]::Round((Get-PSDrive C).Free / 1GB, 1)

The number must be at least 60 (in GB), because building the entire series uses about 45 GB. Part 2 does not use much, but running out of disk space halfway through Part 5 or 6 will waste much more time than checking now.

What Part 2 doesversions.jsonPin versions and hashes->00-fetch.ps1Download, verify, extract->10-deps-vcpkg.ps1Build 9 libs->build\vcpkg_installedPass to Part 3
Top: download, hash check, extract (00-fetch.ps1)versions.jsonVersion + hash00-fetch.ps1curl + tardownloads\13 files, about 560 MBtoolchain\ + src\Extracted, ready to useBottom: Apache libs (10-deps-vcpkg.ps1)deps\apache\vcpkg.jsonports-overlay\openssl\10-deps-vcpkg.ps1vcpkg installbuild\vcpkg_installed\x64-windowslib\ bin\ include\ 18 DLLsPrivate vcpkg repo cloned to toolchain\vcpkg (leaves C:\vcpkg alone)Both rows supply the inputs for Part 3 (20-apache.ps1)
The two scripts in this part: the top row provides source and tools; the bottom row provides libs, all built from source using MSVC.

2. 00-fetch.ps1: Download and Verify Hashes Before Extracting

This script has one job: read versions.json, fetch every entry with a url into downloads\, verify its sha256, and extract it into toolchain\ (tools) or src\ (source). Here is the full script; read through it once before looking at the explanations of each section below.

# 00-fetch.ps1 - download + verify + extract everything pinned in versions.json
# Usage: pwsh scripts\00-fetch.ps1 [-Only php,httpd] [-Force]
param([string[]]$Only, [switch]$Force)
$ErrorActionPreference = 'Stop'
# `pwsh -File ... -Only php,httpd` passes ONE string "php,httpd" (no array parsing in -File mode)
$Only = @($Only | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })
$Root = Split-Path $PSScriptRoot -Parent
$V    = Get-Content "$Root\versions.json" -Raw | ConvertFrom-Json
$LockFile = "$Root\versions.lock.json"
$Lock = if (Test-Path $LockFile) { Get-Content $LockFile -Raw | ConvertFrom-Json -AsHashtable } else { @{} }
$Dl   = "$Root\downloads"; New-Item -ItemType Directory -Force $Dl, "$Root\src" | Out-Null

function Get-One($name, $item, $destDir, $strip) {
    if ($Only -and $name -notin $Only) { return }
    if (-not $item.url) { return }   # git-based sources (imagemagick) are fetched by their own build script
    $file = Join-Path $Dl ([IO.Path]::GetFileName(([uri]$item.url).AbsolutePath))
    if (-not (Test-Path $file) -or $Force) {
        Write-Host "[GET] $name $($item.version)"
        curl.exe -fsSL --retry 3 -o "$file.part" $item.url
        if ($LASTEXITCODE) { throw "download failed: $($item.url)" }
        Move-Item -Force "$file.part" $file
    }
    $sha = (Get-FileHash $file -Algorithm SHA256).Hash.ToLower()
    $want = if ($item.sha256) { $item.sha256 } elseif ($Lock[$name]) { $Lock[$name] } else { $null }
    if ($want -and $sha -ne $want) { throw "[ERR] sha256 mismatch $name`n  got  $sha`n  want $want" }
    if ($item.sha1) {
        $s1 = (Get-FileHash $file -Algorithm SHA1).Hash.ToLower()
        if ($s1 -ne $item.sha1) { throw "[ERR] sha1 mismatch $name" }
    }
    if (-not $want) { Write-Host "[TOFU] $name sha256=$sha" }
    $Lock[$name] = $sha

    if ((Test-Path $destDir) -and -not $Force) { Write-Host "[OK] $name (already extracted)"; return }
    if (Test-Path $destDir) { Remove-Item $destDir -Recurse -Force }
    New-Item -ItemType Directory -Force $destDir | Out-Null
    tar.exe -xf $file -C $destDir --strip-components=$strip
    if ($LASTEXITCODE) { throw "extract failed: $file" }
    Write-Host "[OK] $name -> $destDir"
}

foreach ($p in $V.toolchain.PSObject.Properties) {
    Get-One $p.Name $p.Value (Join-Path $Root $p.Value.dest) $p.Value.strip
}
foreach ($p in $V.sources.PSObject.Properties) {
    Get-One $p.Name $p.Value (Join-Path "$Root\src" "$($p.Name)-$($p.Value.version)") 1
}
$Lock | ConvertTo-Json | Set-Content $LockFile -Encoding utf8

The versions.json file the script reads

I have shortened the hashes for readability. The actual file in the kit contains full 64-character hashes.

{
  "apm_version": "1.0.0",
  "toolchain": {
    "php-sdk": { "version": "2.8.4", "url": "https://github.com/php/php-sdk-binary-tools/archive/refs/tags/php-sdk-2.8.4.zip", "sha256": null, "dest": "toolchain/php-sdk", "strip": 1 },
    "nasm":    { "version": "3.01", "url": "https://www.nasm.us/pub/nasm/releasebuilds/3.01/win64/nasm-3.01-win64.zip", "sha256": null, "dest": "toolchain/nasm", "strip": 1 }
  },
  "sources": {
    "php":       { "version": "8.4.27", "url": "https://www.php.net/distributions/php-8.4.27.tar.xz", "sha256": "dcf93923b0e38e0d…" },
    "httpd":     { "version": "2.4.69", "url": "https://downloads.apache.org/httpd/httpd-2.4.69.tar.bz2", "sha256": "c5e6ebc66e349b87…" },
    "mod_fcgid": { "version": "2.3.9",  "url": "https://archive.apache.org/dist/httpd/mod_fcgid/mod_fcgid-2.3.9.tar.bz2", "sha256": null, "sha1": "f0b6d87dfcfe18b3…" },
    "imagemagick": { "version": "7.1.2-34", "url": null, "sha256": null }
  }
}

The actual file has 4 toolchain entries (php-sdk, strawberry, nasm, winflexbison) and 10 sources entries (php, httpd, apr, apr-util, mod_fcgid, mariadb, pecl-redis, pecl-igbinary, pecl-imagick, imagemagick). There are three things to know before reading the script.

  • "sha256": null does not mean verification is skipped. It means upstream has not published a hash, so the script should use versions.lock.json instead (explained in the next section).
  • strip is the number of folder levels removed during extraction. Most tarballs contain an outer folder named something like httpd-2.4.69\. I strip it off (strip is 1) to get src\httpd-2.4.69\CMakeLists.txt directly, without two nested levels.
  • imagemagick has a url of null because it is not a file to download, but a git tag that Part 5 clones separately. When the script finds an empty url, it silently skips it without printing anything.

Read Get-One in execution order

  1. Filter by -Only If you specify -Only php, every name other than php is skipped immediately. (Before this point, the $Only = @(...) line at the top has already split php,httpd into two names.)
  2. Derive the filename from the URL Use the last component of the URL path as the filename in downloads\, such as httpd-2.4.69.tar.bz2.
  3. Download if the file is missing or -Force is specified Use the curl.exe bundled with Windows (-f fails on HTTP errors, -sS stays quiet but shows errors, -L follows redirects, --retry 3 retries three times). Download to a file ending in .part first, then rename it with Move-Item once the download finishes. If the connection drops midway, only a .part file remains, rather than a partial file that looks complete.
  4. Always calculate the file's sha256 Even if the file already exists, so files left from a previous run are checked again every time.
  5. Choose the comparison value ($want) If versions.json contains a sha256, use it. If it is null, look in versions.lock.json. If neither file has one, there is nothing to compare against.
  6. Mismatch = stop immediately Use throw and print the actual value (got) and the expected value (want). The script never extracts a file whose hash does not match.
  7. Also verify sha1 Only for entries that specify sha1. There is just one: mod_fcgid, because the Apache project publishes a sha1 for it.
  8. If there is nothing to compare against, print [TOFU] Then record the calculated hash in $Lock.
  9. Skip extraction if the destination folder already exists (Print already extracted), unless -Force is specified, in which case delete the folder and extract again. There is a trap here, explained under "If Something Goes Wrong Here."
  10. Extract using tar.exe Bundled with Windows 10 and later (bsdtar), it can read .zip, .tar.bz2, .tar.xz, and .tgz files without installing 7-Zip.
  11. The last line of the script Writes $Lock back to versions.lock.json one final time.

3. How Do Upstream sha256 Hashes Differ from TOFU?

These two terms are central to the trustworthiness of the whole series, so I will explain them separately.

Case 1: Upstream publishes its own hash PHP, httpd, APR, APR-util, and MariaDB publish sha256 hashes on their release pages (php.net has JSON, Apache has .sha256 files alongside the tarballs, and MariaDB has a REST API). I have copied those values into versions.json myself. If someone swaps the file in transit or at a mirror, the hash will not match, and the script stops.

Case 2: TOFU (Trust On First Use) Some upstream projects do not publish hashes, such as GitHub releases (php-sdk, winflexbison, Strawberry Perl) and all three PECL packages. The available approach is to download the file once and record its hash, then compare subsequent downloads against that record. It cannot protect against the first file having been swapped, but it can detect a previously good file changing later.

Entry conditionScript actionResultsha256 in versions.jsonphp httpd apr apr-util mariadbCompare with that fileUpstream publishes hashMatch = extractMismatch = stop nowsha256 is nullBut listed in versions.lock.jsonCompare with lockMy record shipped in the kitMatch = extractMismatch = stop nowsha256 is nullAnd not yet listed in the lock[TOFU] Calculate sha256Save to versions.lock.jsonAlways passes (first use)Future runs compare with thisEntries with sha1 (mod_fcgid) always get an additional sha1 check
The three cases for checking downloaded files. Any comparison that fails stops the script before extraction.

The kit you downloaded includes versions.lock.json. I recorded each hash from the first download on my machine. As a result, your machine will not show any [TOFU] lines on its first run, because every entry already has a value in the lock to compare against, and the hashes for php-sdk, Strawberry Perl, NASM, winflexbison, mod_fcgid, and all three PECL packages will be compared against what I downloaded, rather than trusting the first file encountered. If you want to see TOFU actually work, delete versions.lock.json and run again; you will see [TOFU] <ชื่อ> sha256=<64 ตัวอักษร> instead.

What to know about TOFU

Comparing against the lock only tells you that the file matches the one I previously received. It does not guarantee that the file's upstream source is safe. If you change the version of a TOFU entry (Part 7 explains the procedure), check the upstream release page first to confirm that the tag and file match the announcement, then let the script record the new hash.

4. Run fetch Step by Step

Step 4.1 Run the script

Where: pwsh (regular user), in C:\apm-build. Use the same pwsh -File form that build.cmd uses, which gets past the ExecutionPolicy issue with files downloaded from the internet (files in a zip often carry a "from the internet" marker that causes the RemoteSigned policy to refuse to run them).

pwsh -NoProfile -ExecutionPolicy Bypass -File scripts\00-fetch.ps1

Step 4.2 Watch the output while it runs

The first run takes 3-5 minutes, depending on your connection speed. The largest files are Strawberry Perl (about 290 MB) and MariaDB (about 211 MB). You will see pairs of lines, [GET] followed by [OK], for each entry. I have omitted some entries here.

[GET] php-sdk 2.8.4
[OK] php-sdk -> C:\apm-build\toolchain\php-sdk
[GET] strawberry 5.42.3.1
[OK] strawberry -> C:\apm-build\toolchain\strawberry
...
[GET] php 8.4.27
[OK] php -> C:\apm-build\src\php-8.4.27
[GET] httpd 2.4.69
[OK] httpd -> C:\apm-build\src\httpd-2.4.69
...
[OK] pecl-imagick -> C:\apm-build\src\pecl-imagick-3.8.1

The last line you should see is pecl-imagick. There is no line for imagemagick because it is skipped as explained earlier. If you see [ERR], jump to "If Something Goes Wrong Here."

Step 4.3 Check that all 13 files are present

Get-ChildItem C:\apm-build\downloads | Select-Object Name, @{n='MB';e={[math]::Round($_.Length/1MB,1)}}

You should get 13 rows, looking roughly like this (sizes from my machine).

Name                                          MB
----                                          --
apr-1.7.6.tar.bz2                            0.9
apr-util-1.6.5.tar.bz2                       0.4
httpd-2.4.69.tar.bz2                         8.1
igbinary-3.2.16.tgz                          0.1
imagick-3.8.1.tgz                            0.3
mariadb-11.8.9.tar.gz                      211.4
mod_fcgid-2.3.9.tar.bz2                      0.1
nasm-3.01-win64.zip                          0.6
php-8.4.27.tar.xz                           13.2
php-sdk-2.8.4.zip                           34.4
redis-6.3.0.tgz                              0.4
strawberry-perl-5.42.3.1-64bit-portable.zip 290.6
win_flex_bison-2.5.25.zip                    1.0

Step 4.4 Check that the source was extracted

Get-ChildItem C:\apm-build\src -Name
Get-ChildItem C:\apm-build\toolchain -Name
apr-1.7.6
apr-util-1.6.5
httpd-2.4.69
mariadb-11.8.9
mod_fcgid-2.3.9
pecl-igbinary-3.2.16
pecl-imagick-3.8.1
pecl-redis-6.3.0
php-8.4.27
nasm
php-sdk
strawberry
winflexbison

There are 9 folders under src and 4 under toolchain (vcpkg is not there yet; it will appear in Section 7, and imagemagick-windows will appear in Part 5).

Step 4.5 Check that extraction used the correct folder level

This checks that strip worked. If extraction produced two nested levels, these files will not be found, and Part 3 will fail with a confusing error.

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

All four lines must return True.

Step 4.6 Check that the downloaded tools run

I check these two because vcpkg and OpenSSL depend on them later in the build, and using the wrong ones causes the most trouble.

C:\apm-build\toolchain\strawberry\perl\bin\perl.exe -e 'print "$^V $^O\n"'
C:\apm-build\toolchain\nasm\nasm.exe -v
v5.42.3 MSWin32
NASM version 3.01 compiled on Oct 10 2025

The word MSWin32 matters a great deal, because the perl bundled with Git for Windows is the cygwin variant (it reports cygwin instead), which generates Unix paths and breaks the OpenSSL build. That is why you must use this downloaded Strawberry Perl.

Step 4.7 Run it again to check that it skips work

pwsh -NoProfile -ExecutionPolicy Bypass -File scripts\00-fetch.ps1

This time it finishes in a few seconds (most of the time is spent recalculating hashes), because all the files are present. The script just recalculates the hashes and prints that the files have already been extracted. Here is the result (from my actual log).

[OK] php-sdk (already extracted)
[OK] strawberry (already extracted)
[OK] nasm (already extracted)
[OK] winflexbison (already extracted)
[OK] php (already extracted)
...
[OK] pecl-imagick (already extracted)

Step 4.8 (Optional) Check a hash manually against upstream

If you would rather not trust my script, compare it yourself. Here is an example using httpd.

Get-FileHash C:\apm-build\downloads\httpd-2.4.69.tar.bz2 -Algorithm SHA256 | Select-Object -ExpandProperty Hash

Then open https://downloads.apache.org/httpd/httpd-2.4.69.tar.bz2.sha256 in your browser and compare. The values must match (PowerShell prints uppercase, while Apache's file uses lowercase; ignore that difference). Mine starts with C5E6EBC66E34.

Use -Only and -Force to download selected entries

Part 7 will use these options when updating versions. You can pass several names at once straight through -File, separated by a comma.

pwsh -NoProfile -ExecutionPolicy Bypass -File scripts�-fetch.ps1 -Only php,httpd

This works because of the line added under $ErrorActionPreference (the one starting $Only = @(...)). pwsh -File does not turn php,httpd into an array. It passes one single string, "php,httpd", so $Only has one element and the name httpd matches nothing. The script then skips every entry silently, with no error. The current script splits the string itself before using it: it runs every value in $Only through -split ',', trims the ends with .Trim(), drops empty values with Where-Object { $_ }, and wraps the result in @(...) so it is always an array. So -Only php, -Only php,httpd and -Only "php, httpd" all behave the same.

To check it split correctly, run it and look for [OK] or [GET] lines for both names (if the files and folders already exist you get [OK] php (already extracted) and [OK] httpd (already extracted)). Older kits needed -Command "& .\scripts�-fetch.ps1 -Only php,httpd" instead, because they did not split the string. The current kit does not.

For -Force, just add it to the end of the command. It downloads the file again, overwrites it, and deletes the destination folder before extracting again.

5. vcpkg and the Libs Apache Needs

Apache httpd itself contains only its own C code. The ability to handle HTTPS, HTTP/2, and brotli compression comes from external libs. If you have installed Apache on Linux, a single apt install command gets all these libs, but on Windows nobody manages that for you. I use vcpkg, Microsoft's C/C++ package manager, to download each lib's source and compile it locally with MSVC so its ABI matches the Apache I am going to build.

manifest: Specify what you need

vcpkg has two modes. The one I use is called manifest mode: list the required libs in the project's vcpkg.json file, then run vcpkg install. This file is at deps\apache\vcpkg.json (I have shortened the baseline hash).

{
  "name": "apm-apache-deps",
  "version-string": "1",
  "builtin-baseline": "bd8298ae21…",
  "dependencies": [
    "openssl", "pcre2", "zlib", "nghttp2", "brotli",
    "expat", "libxml2", "jansson", "curl"
  ]
}

builtin-baseline is the vcpkg repository commit that pins every lib to a specific version. Without that pin, each vcpkg update could give us different lib versions without us realizing it, so builds on two machines from the same run would differ. In the actual file, the baseline is a 40-character commit id starting with bd8298ae21.

Here is what all 9 libs do, based on what httpd's configure detected in my log (the Summary of feature detection section) and what dumpbin confirms in Part 3.

lib Used by Notes
openssl mod_ssl, mod_md, apr_crypto_openssl All HTTPS/TLS (covered in Section 6)
pcre2 libhttpd.dll Regex for mod_rewrite, <LocationMatch>, etc.
zlib mod_deflate, libcurl, libxml2 gzip/deflate compression
nghttp2 mod_http2 HTTP/2
brotli mod_brotli brotli compression
expat libaprutil XML parsing for APR-util
libxml2 mod_xml2enc Handles encoding in proxied XML/HTML documents
jansson mod_md Reads/writes ACME JSON
curl mod_md Lets mod_md communicate with Let's Encrypt

vcpkg also pulls in what these libs need during the build, such as libiconv (required by libxml2) and vcpkg-cmake. The * ... lines in the log are entries installed as dependencies. vcpkg downloads and stores its own tools (CMake, Ninja, Perl, NASM, msys2) in toolchain\vcpkg\downloads\tools\, without touching the ones installed on your machine.

6. OpenSSL 3.5 LTS with an Overlay Port

This is a choice I made myself, and I need to explain why. The vcpkg version referenced by the baseline provides OpenSSL 3.6.5, a non-LTS release whose support ends in November 2026, which is next month. OpenSSL 3.5 is an LTS release supported until April 2030. For a web server that I intend to keep current with every security patch, I chose LTS so I would not need another major version migration soon.

vcpkg has an overlay port mechanism for exactly this: tell it to use a port (the build recipe for a lib) from our folder instead of the one in the vcpkg repository. Our folder is ports-overlay\openssl\ in the kit. It contains the complete OpenSSL recipe (portfile, patches, and vcpkg.json), set to version 3.5.9. Two places need updating whenever the version changes: the version number in vcpkg.json and the tarball's SHA512 in portfile.cmake.

{
  "name": "openssl",
  "version": "3.5.9",
  ...
}
vcpkg_from_github(
    OUT_SOURCE_PATH SOURCE_PATH
    REPO openssl/openssl
    REF "openssl-${VERSION}"
    SHA512 ab29a6ed40dd8c74…
    PATCHES
        cmake-config.patch
        command-line-length.patch
        script-prefix.patch
        windows/install-layout.patch
        windows/install-pdbs.patch
        windows/install-programs.diff
        unix/android-cc.patch
        unix/move-openssldir.patch
        unix/no-empty-dirs.patch
        unix/no-static-libs-for-shared.patch
)

The REF "openssl-${VERSION}" line gets the number from vcpkg.json, so changing the version in one place carries through to both files. SHA512 is the hash of https://github.com/openssl/openssl/archive/openssl-3.5.9.tar.gz. If the file vcpkg downloads has a different hash, it stops just as 00-fetch.ps1 does. All 10 patches in the list come from vcpkg itself. I tested them with 3.5.9 and they worked without modification, but you must always test other versions yourself.

vcpkg install --overlay-ports=ports-overlayManifest requests opensslvcpkg registry (baseline)openssl 3.6.5non-LTS, support ends Nov. 2026ports-overlay\openssl (selected)openssl 3.5.9 LTSSupported until Apr. 2030SkippedOverlay winsNot built on this machinelibssl-3-x64.dll libcrypto-3-x64.dllProductVersion 3.5.9
The overlay port is searched before the vcpkg repository, giving us the LTS OpenSSL release instead of the 3.6 release referenced by the baseline.
Why not use the PHP team's OpenSSL?

Building PHP in Part 6 provides another set of OpenSSL DLLs from the PHP team's deps collection (I found version 3.0.22 at that point). These two sets are separate. Apache uses the 3.5.9 I build myself, while PHP uses the PHP team's set to match the official PHP ABI. Details are in Part 6.

7. 10-deps-vcpkg.ps1 Line by Line, Then Run It

Here is the full script.

# 10-deps-vcpkg.ps1 - build Apache's third-party libs from source with vcpkg (manifest mode)
# OpenSSL comes from ports-overlay\openssl (3.5 LTS) instead of the vcpkg default.
$ErrorActionPreference = 'Stop'
$Root  = Split-Path $PSScriptRoot -Parent
$Vcpkg = "$Root\toolchain\vcpkg\vcpkg.exe"
$Out   = "$Root\build\vcpkg_installed"
New-Item -ItemType Directory -Force "$Root\build\logs" | Out-Null

# private vcpkg checkout (never the shared C:\vcpkg), at least as new as the manifest baseline
if (-not (Test-Path $Vcpkg)) {
    $baseline = (Get-Content "$Root\deps\apache\vcpkg.json" -Raw | ConvertFrom-Json).'builtin-baseline'
    git clone --quiet https://github.com/microsoft/vcpkg.git "$Root\toolchain\vcpkg"
    git -C "$Root\toolchain\vcpkg" checkout --quiet $baseline
    & "$Root\toolchain\vcpkg\bootstrap-vcpkg.bat" -disableMetrics
    if (-not (Test-Path $Vcpkg)) { throw '[ERR] vcpkg bootstrap failed' }
}

$env:Path = ($env:Path -split ';' | Where-Object { $_ -notmatch '(?i)xampp' }) -join ';'
$env:VCPKG_DISABLE_METRICS = '1'
& $Vcpkg install --triplet x64-windows `
    --x-manifest-root="$Root\deps\apache" `
    --x-install-root="$Out" `
    --overlay-ports="$Root\ports-overlay" `
    --clean-after-build 2>&1 | Tee-Object "$Root\build\logs\vcpkg-apache.log" | Select-Object -Last 25
if ($LASTEXITCODE) { throw "[ERR] vcpkg install failed (log: build\logs\vcpkg-apache.log)" }
Write-Host "[OK] deps -> $Out\x64-windows"

Read it in execution order

  1. Lines 4-7 Set the paths for vcpkg.exe and the output (build\vcpkg_installed), and create build\logs.
  2. The if (-not (Test-Path $Vcpkg)) block Runs only the first time, when vcpkg is not yet present. Read the commit id from builtin-baseline in the manifest, git clone the vcpkg repository into toolchain\vcpkg, git checkout that commit, then run bootstrap-vcpkg.bat, which downloads vcpkg.exe. This is deliberately a private checkout in the project folder, rather than using a C:\vcpkg installation you may share with other work, so the two projects do not interfere with each other.
  3. The $env:Path = ... line Removes paths containing xampp from PATH in this process only, preventing XAMPP's perl, php, etc. from being picked up instead of the intended tools. If you do not have XAMPP, this line does nothing.
  4. VCPKG_DISABLE_METRICS Disables sending vcpkg usage statistics to Microsoft.
  5. The vcpkg install command Has 5 options.
    • --triplet x64-windows Specifies x64 with DLLs (not static), producing .dll files alongside .lib files, which is what Apache needs.
    • --x-manifest-root Points to the folder containing vcpkg.json.
    • --x-install-root Installs the output into the project's build\vcpkg_installed, rather than vcpkg's shared location.
    • --overlay-ports Points to ports-overlay so OpenSSL 3.5.9 wins (Section 6).
    • --clean-after-build Deletes each lib's temporary build tree as soon as it finishes, saving several GB of disk space.
  6. The 2>&1 | Tee-Object ... | Select-Object -Last 25 pipeline Merges errors into the output, writes the full log to build\logs\vcpkg-apache.log, and shows only the last 25 lines on screen. The display is short because vcpkg produces a lot of output.
  7. $LASTEXITCODE If vcpkg returns anything other than 0, the script uses throw and tells you where the log is.

Step 7.1 Run the script

Where: pwsh (regular user), in C:\apm-build. Expect the first run to take a while, about 45 minutes on my machine (i5-12500H), with OpenSSL and libiconv taking the longest. The screen will stay quiet for a long time during those builds; it has not hung. To check whether it is still working, open Task Manager and look for a cl.exe or nmake process using CPU.

pwsh -NoProfile -ExecutionPolicy Bypass -File scripts\10-deps-vcpkg.ps1

Step 7.2 Check the final output

The screen shows only the last 25 lines, ending with vcpkg's summary and the script's [OK]. Below is actual output from my log when I ran it again with the cache ready (which is why it says already installed). Your first run will have "will be built and installed" lines instead, but the last 3 lines will look the same apart from the time.

pcre2 provides pkg-config modules:

  # Posix compatible interface to libpcre2-8
  libpcre2-posix

All requested installations completed successfully in: 323 us
[OK] deps -> C:\apm-build\build\vcpkg_installed\x64-windows

The 323 us in my run means vcpkg did no work at all. On your first run, this will be measured in minutes. To see the list of installed packages, search the full log.

Select-String -Path C:\apm-build\build\logs\vcpkg-apache.log -Pattern 'x64-windows@' | ForEach-Object Line

The output on my machine (the cached run) shows every lib's version.

    brotli:[email protected]
    curl[core,sspi,ssl]:[email protected]
    expat:[email protected]
    jansson:[email protected]
  * libiconv:[email protected]#1
    libxml2[core,zlib,iconv]:[email protected]
    nghttp2:[email protected]
    openssl:[email protected]
    pcre2[core,platform-default-features,jit]:[email protected]
  * vcpkg-cmake:x64-windows@2025-08-07
  * vcpkg-cmake-config:x64-windows@2026-07-21
  * vcpkg-cmake-get-vars:x64-windows@2025-05-29
  * vcpkg-make:x64-windows@2026-07-09#2
    zlib:[email protected]#2

Look for openssl:[email protected] as confirmation that the overlay worked. If you see 3.6.5, the overlay was not used; go back and check that ports-overlay\openssl actually exists.

Step 7.3 Check the .lib files Apache will link against

Get-ChildItem C:\apm-build\build\vcpkg_installed\x64-windows\lib -File -Name
brotlicommon.lib
brotlidec.lib
brotlienc.lib
charset.lib
iconv.lib
jansson.lib
libcrypto.lib
libcurl.lib
libexpat.lib
libssl.lib
libxml2.lib
nghttp2.lib
pcre2-16.lib
pcre2-32.lib
pcre2-8.lib
pcre2-posix.lib
z.lib

There must be 17 files. If libssl.lib or libcrypto.lib is missing, OpenSSL did not build successfully; see "If Something Goes Wrong Here."

Step 7.4 Check the versions of the DLLs actually built

This confirms that the files copied alongside httpd.exe in Part 3 are the versions I intended, rather than files from some unknown location.

$b = 'C:\apm-build\build\vcpkg_installed\x64-windows\bin'
foreach ($n in 'libcrypto-3-x64','libssl-3-x64','libcurl','libxml2','z') { "{0,-18} {1}" -f $n, (Get-Item "$b\$n.dll").VersionInfo.ProductVersion }
libcrypto-3-x64    3.5.9
libssl-3-x64       3.5.9
libcurl            8.23.0
libxml2            2.15.4
z                  1.3.2

The filename libcrypto-3-x64.dll still contains 3 even though its contents are version 3.5.9, because the number in the name is the OpenSSL ABI's major version, not the full version number.

Step 7.5 Check that vcpkg points to the correct commit

git -C C:\apm-build\toolchain\vcpkg log -1 --format=%h
bd8298ae21

It must start with the same characters as builtin-baseline in the manifest file.

Step 7.6 Confirm that the script can be run again

Run Step 7.1 again. It will finish in a few seconds because vcpkg keeps a binary cache of each package locally (by default in %LOCALAPPDATA%\vcpkg\archives). This means that changing one lib's version does not recompile the others. When I measured a repeat run of the entire build.cmd, the vcpkg step took 0.1 minutes.

The result

You have C:\apm-build\build\vcpkg_installed\x64-windows, containing include\, lib\ (17 .lib files), and bin\ (18 DLLs). This is everything Part 3 will pull in.

8. If Something Goes Wrong Here

This table covers issues I actually encountered or that the scripts are designed to catch. Where I have not encountered an issue myself, I have marked it as based on reading the code.

Symptom Cause Fix
[ERR] sha256 mismatch <ชื่อ> with got and want The downloaded file does not match the pinned hash. The download may be incomplete, the mirror may have served the wrong file, or the upstream file may have changed. Delete that file from downloads\ and run again. If the same thing happens, do not just change the hash to make it pass. Compare it against the upstream page first (Step 4.8).
download failed: <url> Connection lost, corporate proxy blocking the download, or upstream URL changed. Try opening the URL in your browser. If that works but curl.exe does not, check the proxy. Leftover .part files can be deleted.
The run is silent, with no [OK] lines A name in -Only does not match a key in versions.json (for example apache instead of httpd), so every entry is skipped. Or an older kit that does not split a,b itself. Compare the name with the keys in versions.json. With an old kit, download the new one, or run through -Command "& .\scripts�-fetch.ps1 -Only a,b".
[OK] <ชื่อ> (already extracted) appears, but files inside are missing Extraction stopped midway (disk full). The destination folder had already been created, so the next run sees it and skips extraction (based on reading the code). Delete that folder from src\ or toolchain\ and run again, or add -Force.
extract failed: <ไฟล์> tar.exe cannot read the file, the file is corrupt, or the path is too long. Delete the file from downloads\ and download it again. If it still happens, check that the project folder is not nested too deeply. Use C:\apm-build as instructed.
A parameter cannot be found that matches parameter name 'AsHashtable' Running in Windows PowerShell 5.1 instead of pwsh 7. Open pwsh (Step 1.1).
OpenSSL .lib files are missing / [ERR] vcpkg install failed One of the libs failed to build. Open build\logs\vcpkg-apache.log and look for the first error message. vcpkg usually gives the package's individual log filename near the end.
OpenSSL fails with strange Unix-style paths Git's perl (cygwin) was picked up from PATH. Follow Step 4.6 until you get MSWin32, and remove Git's perl folder from that session's PATH if it is set there.
warning: ... ignoring mismatched VCPKG_ROOT environment value C:\vcpkg A VCPKG_ROOT variable is set for another vcpkg installation on the machine. This is only a warning, not a problem. vcpkg uses the project's copy as intended (seen in my actual log).
It takes twice as long Defender is scanning tens of thousands of .obj files. Set the exclusion described in Part 1 (requires admin).
Running again still recompiles OpenSSL even though nothing changed The binary cache in %LOCALAPPDATA%\vcpkg\archives was deleted, or the compiler changed (updating MSVC changes the compiler hash). Based on vcpkg's behavior of including the compiler in its hash. Let it finish. The next run will be fast again.

If you encounter a symptom outside the table, always open the log first. Console output is limited to the last 25 lines, but the full log is at C:\apm-build\build\logs\vcpkg-apache.log, and the first error message identifying the actual cause is often above those 25 lines.

9. Checklist Before Part 3

Every item must be true before continuing. If any item fails, return to the referenced step.

  1. C:\apm-build\downloads contains 13 files, and running 00-fetch.ps1 again reports already extracted on every entry (Steps 4.3 and 4.7).
  2. C:\apm-build\src contains 9 folders, and Test-Path returns True for all four CMakeLists.txt files (Steps 4.4 and 4.5).
  3. Strawberry's perl.exe reports MSWin32 (Step 4.6).
  4. C:\apm-build\build\vcpkg_installed\x64-windows\lib contains 17 .lib files, including libssl.lib and libcrypto.lib (Step 7.3).
  5. libcrypto-3-x64.dll has a ProductVersion of 3.5.9 (Step 7.4).
  6. C:\apm-build\build\logs\vcpkg-apache.log ends with All requested installations completed successfully (Step 7.2).
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 1: Prepare the Tools · Next: Part 3: Build Apache httpd

Part 3 will bring together the source in src\ and the libs in build\vcpkg_installed to build Apache httpd and mod_fcgid, and you will see how the script finds the DLLs that need to sit alongside httpd.exe. If you have followed along this far, the longest part of building Apache is behind you. I will come back and make further edits 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