Build APM on Windows, Part 7: Deploy, Run, and Apply Security Patches
The finished builds are still in stage\, a temporary folder. Now I will install them into C:\APM, start them and see them running, and document the next update so I can repeat it without relying on memory.
I have reached the final part of this series, and it is the one I most wanted to make reusable. The whole reason I started building Apache, PHP, and MariaDB myself was that when PHP released 8.4.27 to fix two RCE vulnerabilities on 21 October 2026 (the full story is in the php2shell news article), I wanted to change the version numbers in one file and rebuild within a day, without waiting for a bundle like XAMPP, which on my machine was still stuck on PHP 8.2.12 from 2023.
There are three jobs now. The first is to move the contents of stage\ into C:\APM using deploy.cmd. The second is to start MariaDB and Apache for the first time, test them, and shut them down. I encountered one actual bug here and will cover it in full. The third is the update procedure for the next security release. Every step is numbered and says which terminal to use, what to expect, and how to check it. Besides those three jobs, this part now has two extra sections: letting other machines on the LAN in, and five more bugs I found after the series went out.
Always use PowerShell 7 (pwsh) as a regular user. There is no need to run as admin. Commands run from the build folder should be run in C:\apm-build. Commands that call programs in C:\APM always use full paths and can be run from any folder. APM and apm-build use different capitalization; be careful not to mix them up.
After the series went out I changed the deploy script and the templates in several places. Every command, script, and table in this part now matches the new kit. The additions are -Lan, trust-ca.ps1, new-cert.ps1, firewall-lan.ps1, and set-path.ps1. If you downloaded the kit before that, read the update instructions in the section "Bugs found after the series went out" below.
Check the files and choose ports before deploying
deploy.cmd does not start anything. It only copies files and creates config files, so it is safe to run again. There are four prerequisites to check first, though.
Step 1: Open a terminal and go to the build folder
cd C:\apm-build
Step 2: Check that stage contains all three components
Test-Path C:\apm-build\stage\apache\bin\httpd.exe
Test-Path C:\apm-build\stage\php\php.exe
Test-Path C:\apm-build\stage\mariadb\bin\mariadbd.exe
The expected output is three lines of True.
True
True
True
If any line is False, go back and finish Part 3 (Apache), 4 (MariaDB), or 6 (PHP) first. If you skip ahead, deploy.cmd will stop with [ERR] ...\stage\apache missing - build first (the folder name changes to match the missing component).
Step 3: Check that no old APM instance is running
The script refuses to deploy if any httpd, mariadbd, or php-cgi process from C:\APM is running, because Windows will not overwrite files that are in use. The new version also refuses if the Windows service APM-Apache or APM-MariaDB is running. Check both with these commands.
Get-Process httpd,mariadbd,php-cgi -ErrorAction SilentlyContinue | Where-Object { $_.Path -like 'C:\APM\*' }
Get-Service APM-Apache,APM-MariaDB -ErrorAction SilentlyContinue
The expected result is no output at all from both commands. If a table appears from the first one, a process is still running. Follow the APM shutdown steps in the first-start section before returning here. The second command shows a row only if you ever installed the ready-made APM as a service. If there is a row and its status is not Stopped, stop it first (Stop-Service needs an admin pwsh). The separate check exists because services run as the LocalService account, whose .Path Get-Process cannot see. If you forget, the deploy script stops with [ERR] service APM-Apache is running - stop APM before deploying.
Step 4: Check that the ports are free
The script's defaults deliberately avoid the ports XAMPP uses so the two can be installed side by side.
| Option | Default | XAMPP uses | Purpose |
|---|---|---|---|
-HttpPort |
8080 | 80 | Apache HTTP |
-HttpsPort |
8443 | 443 | Apache HTTPS |
-DbPort |
3307 | 3306 | MariaDB |
-Target |
C:\APM |
- | Installation folder |
-Lan |
Off | - | Apache listens on every interface (0.0.0.0) instead of 127.0.0.1, and the certificate covers the machine name and LAN IPs (MariaDB always stays on 127.0.0.1). See the LAN section |
-ResetConfig |
Off | - | Recreate config files from templates\, overwriting existing files; also issues a new server certificate and syncs data\mysql\my.ini |
Check that nothing is using these three ports.
Get-NetTCPConnection -LocalPort 8080,8443,3307 -ErrorAction SilentlyContinue
The expected result is no output. If any rows appear, another program is using a port. Choose another port by passing an option when running deploy.cmd, such as -HttpPort 8081. I use all the defaults in this series. The installation takes approximately 650 MB on disk (roughly apache 20, php 124, mariadb 201, data 302).
What 90-deploy.ps1 does, section by section
The deploy.cmd wrapper is very short. It checks that pwsh exists, then calls the script and forwards the arguments (this file is unchanged from the first release; new options such as -Lan reach the script through %*).
@echo off
rem deploy.cmd - install stage\ into C:\APM (does not start anything)
rem deploy.cmd defaults: C:\APM, http 8080, https 8443, db 3307
rem deploy.cmd -Target D:\APM -HttpPort 80 -HttpsPort 443 -DbPort 3306
rem deploy.cmd -ResetConfig re-render config files from templates\
setlocal
where pwsh >nul 2>&1 || (echo [ERR] PowerShell 7 ^(pwsh^) not found - see https://linuxthai.org/article/apm-build-windows-1-prepare-tools & exit /b 1)
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\90-deploy.ps1" %*
exit /b %ERRORLEVEL%
This is the actual script, the new version from the kit. I have included the entire file without cutting any lines.
# 90-deploy.ps1 - install the built stage\ into a runtime folder (default C:\APM). Does NOT start anything.
# pwsh -File scripts\90-deploy.ps1 [-Target C:\APM] [-HttpPort 8080] [-HttpsPort 8443] [-DbPort 3307] [-Lan]
# Re-running upgrades binaries in place; existing config files, data, www, ssl and logs are kept
# (use -ResetConfig to re-render config from templates\).
# -Lan: Apache listens on all interfaces + the cert covers this PC's name and LAN IPs. MariaDB always
# stays on 127.0.0.1 (root has no password). Windows Firewall still needs scripts\firewall-lan.ps1 (admin).
param(
[string]$Target = 'C:\APM',
[int]$HttpPort = 8080, [int]$HttpsPort = 8443, [int]$DbPort = 3307, # XAMPP keeps 80/443/3306 meanwhile
[switch]$Lan,
[switch]$ResetConfig
)
$Bind = if ($Lan) { '0.0.0.0' } else { '127.0.0.1' }
$LanIPs = @()
if ($Lan) {
$LanIPs = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.AddressState -eq 'Preferred' } |
ForEach-Object IPAddress
}
$ErrorActionPreference = 'Stop'
$Root = Split-Path $PSScriptRoot -Parent
$Stage = "$Root\stage"
foreach ($c in 'apache','php','mariadb') { if (-not (Test-Path "$Stage\$c")) { throw "[ERR] $Stage\$c missing - build first" } }
foreach ($exe in 'httpd','mariadbd','php-cgi') {
$p = Get-Process $exe -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "$Target\*" }
if ($p) { throw "[ERR] $exe from $Target is running - stop APM before deploying" }
}
# services run as LocalService: their processes are not visible to Get-Process .Path -> ask the SCM
$svc = Get-Service 'APM-Apache','APM-MariaDB' -ErrorAction SilentlyContinue | Where-Object Status -ne 'Stopped'
if ($svc) { throw "[ERR] service $($svc.Name -join ', ') is running - stop APM before deploying" }
# 1. binaries (robocopy /MIR per component, minus debug symbols and dev files)
# /XD and /XF match names on both sides, so they also keep /MIR from deleting the target's conf/logs/ini
$skip = @{
apache = @('/XD', 'include', 'lib', 'conf', 'logs', '/XF', '*.pdb', '*.lib', '*.exp')
php = @('/XF', '*.pdb', '*.lib', '*.exp', 'php.ini')
# NOT 'lib' here: mariadb\lib\plugin holds the plugin DLLs (ha_archive, auth_ed25519, ...); *.lib files are dropped by /XF
mariadb = @('/XD', 'include', 'symbols', 'sql-bench', '/XF', '*.pdb', '*.lib', '*.exp', 'my.ini')
}
foreach ($c in 'apache','php','mariadb') {
robocopy "$Stage\$c" "$Target\$c" /MIR /R:1 /W:1 /NFL /NDL /NJH /NJS /NP @($skip[$c]) | Out-Null
if ($LASTEXITCODE -ge 8) { throw "[ERR] copy $c failed (robocopy rc=$LASTEXITCODE)" }
Write-Host "[OK] $c -> $Target\$c"
}
# templates + tools travel with the install (re-render config later, new-cert.ps1)
foreach ($d in 'templates','tools') {
robocopy "$Root\$d" "$Target\$d" /MIR /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
if ($LASTEXITCODE -ge 8) { throw "[ERR] copy $d failed" }
}
# license of the build scripts (MIT + Commons Clause)
Copy-Item "$Root\LICENSE" "$Target\LICENSE.txt" -Force
# Apache conf data files (mime.types, magic ...) - copy, but never overwrite an edited httpd.conf
New-Item -ItemType Directory -Force "$Target\apache\conf\extra", "$Target\apache\conf\vhosts" | Out-Null
Get-ChildItem "$Stage\apache\conf" -File | Where-Object Name -ne 'httpd.conf' | Copy-Item -Destination "$Target\apache\conf" -Force
# 2. runtime folders
foreach ($d in 'www\default','logs\apache','logs\php','logs\mariadb','tmp\sessions','tmp\uploads','tmp\opcache','ssl','data') {
New-Item -ItemType Directory -Force "$Target\$d" | Out-Null
}
# 3. config from templates (only if missing, or -ResetConfig)
# CACHE_ID: per-folder OPcache segment name, derived from the install path
$sha1 = [Security.Cryptography.SHA1]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes(($Target -replace '\\','/').ToLower()))
$cacheId = 'apm' + (-join ($sha1[0..3] | ForEach-Object { $_.ToString('x2') }))
$vars = @{ APM_ROOT = ($Target -replace '\\','/'); HTTP_PORT = $HttpPort; HTTPS_PORT = $HttpsPort; DB_PORT = $DbPort; BIND = $Bind; CACHE_ID = $cacheId }
function Render($tpl, $dest, [switch]$WinPaths) {
if ((Test-Path $dest) -and -not $ResetConfig) { Write-Host "[KEEP] $dest"; return }
$t = Get-Content "$Root\templates\$tpl" -Raw
foreach ($k in $vars.Keys) {
$v = if ($WinPaths -and $k -eq 'APM_ROOT') { $Target } else { "$($vars[$k])" }
$t = $t.Replace("{{$k}}", $v)
}
Set-Content $dest $t -Encoding ascii -NoNewline
Write-Host "[OK] $dest"
}
Render 'httpd.conf' "$Target\apache\conf\httpd.conf"
Render 'apm-ssl.conf' "$Target\apache\conf\extra\apm-ssl.conf"
Render 'php.ini' "$Target\php\php.ini" -WinPaths
Render 'my.ini' "$Target\mariadb\my.ini"
Copy-Item "$Root\versions.json" "$Target\versions.json" -Force
# 4. CA bundle for curl/openssl in PHP (Mozilla bundle from curl.se, sha256-checked)
$ca = "$Target\ssl\cacert.pem"
if (-not (Test-Path $ca) -or $ResetConfig) {
curl.exe -fsSL -o "$ca.part" https://curl.se/ca/cacert.pem
$want = ((curl.exe -fsSL https://curl.se/ca/cacert.pem.sha256) -split '\s+')[0]
if ((Get-FileHash "$ca.part" -Algorithm SHA256).Hash -ne $want) { Remove-Item "$ca.part"; throw '[ERR] cacert.pem sha256 mismatch' }
Move-Item -Force "$ca.part" $ca
Write-Host "[OK] $ca"
}
# 5. local CA + localhost certificate (generated with APM's own php.exe). The CA is created once and
# kept; the server cert is re-issued on -ResetConfig/-Lan so its SAN matches how the site is reached.
if (-not (Test-Path "$Target\ssl\localhost.crt") -or $ResetConfig -or $Lan) {
$certHosts = @('localhost', '127.0.0.1', '::1')
if ($Lan) { $certHosts += @($env:COMPUTERNAME.ToLower()) + $LanIPs }
& "$Target\php\php.exe" -n -d "extension_dir=`"$Target\php\ext`"" -d extension=openssl `
"$Root\tools\mkcert.php" "$Target\ssl" @certHosts
if ($LASTEXITCODE) { throw '[ERR] certificate generation failed' }
}
# 6. default page
if (-not (Test-Path "$Target\www\default\index.php")) {
Copy-Item "$Root\templates\index.php" "$Target\www\default\index.php"
}
# 7. MariaDB datadir (bootstrap only - no network listener is opened)
if (-not (Test-Path "$Target\data\mysql\mysql")) {
& "$Target\mariadb\bin\mariadb-install-db.exe" --datadir="$Target\data\mysql" --port=$DbPort --config="$Target\mariadb\my.ini" --silent
if ($LASTEXITCODE) { throw '[ERR] mariadb-install-db failed' }
Write-Host "[OK] datadir $Target\data\mysql (root@localhost, no password)"
} elseif ($ResetConfig) {
# the server runs with <datadir>\my.ini (written once by mariadb-install-db) -> keep it in sync
Copy-Item "$Target\mariadb\my.ini" "$Target\data\mysql\my.ini" -Force
Write-Host "[OK] $Target\data\mysql\my.ini (synced)"
}
# 8. config syntax check (parses only, binds nothing)
& "$Target\apache\bin\httpd.exe" -t
if ($LASTEXITCODE) { throw '[ERR] httpd -t failed' }
$live = Select-String -Path "$Target\apache\conf\httpd.conf" -Pattern '^Listen\s+(\S+)' | ForEach-Object { $_.Matches[0].Groups[1].Value }
Write-Host "[OK] APM deployed to $Target (http :$HttpPort, https :$HttpsPort, db 127.0.0.1:$DbPort) - not started"
Write-Host " httpd.conf Listen: $live"
if ($Lan) {
if ($live -like '127.0.0.1:*') { Write-Host '[WARN] httpd.conf was kept and still binds 127.0.0.1 - add -ResetConfig to switch it to LAN' }
foreach ($ip in $LanIPs) { Write-Host " LAN: http://${ip}:$HttpPort/ https://${ip}:$HttpsPort/" }
Write-Host ' Firewall: run scripts\firewall-lan.ps1 once as Administrator'
}
(The first block is a batch file, but I use a powershell fence because this site has no batch syntax highlighter.)
I divide the script into eight sections based on the comments in the file, plus a new header part, following the order in which they run.
Header: the -Lan switch and the variables set first
param has one new switch, -Lan. Under param the script sets $Bind to 0.0.0.0 when -Lan is given and 127.0.0.1 when it is not. That value later becomes {{BIND}} in the Apache templates. With -Lan it also collects the machine's IPv4 addresses in $LanIPs, keeping only addresses that do not start with 127., do not start with 169.254. (the address a machine gives itself when DHCP fails) and are in the Preferred state. They are used in two places: in the certificate's SAN, and in the URLs printed at the end.
Prerequisite checks (at the top)
The script checks three things. If one fails it runs throw before touching any file.
stage\apache,stage\php,stage\mariadbmust exist, otherwise it prints[ERR] <path>\stage\<component> missing - build first.- No
httpd,mariadbd, orphp-cgifromC:\APMmay be running, otherwise[ERR] <exe name> from C:\APM is running - stop APM before deploying. - New: no Windows service
APM-ApacheorAPM-MariaDBmay be in any state other than Stopped, otherwise[ERR] service APM-Apache is running - stop APM before deploying(the names of the running services are joined with commas). The separate check is needed because services run as theLocalServiceaccount, andGet-Processcannot read the.Pathof those processes from your account, so the check above cannot see them. The script asks the Service Control Manager directly instead. Those service names belong to the ready-made APM that I distribute separately (see the "Don't want to build it yourself" box at the end of this part). If you build and deploy by following this series, this check always passes.
Section 1: Copy binaries with robocopy
robocopy /MIR makes the destination match the source exactly, including deleting files that are absent from the source. That makes it dangerous if used carelessly, so the script adds two safeguards.
/XD(exclude directories) and/XF(exclude files) apply to both the source and destination. This keeps/MIRfrom deleting or overwriting Apache'sconfandlogs, PHP'sphp.ini, and MariaDB'smy.iniat the destination.- It does not copy
*.pdb,*.lib,*.exp, or theincludefolders (development files), making the installed binaries smaller than stage.
What changed from the first release: the mariadb list used to have lib in /XD as well. It is gone now, and a comment in the script says why. I tell the whole story in the bug section below. In short, /XD lib skips folders named lib at every level, including mariadb\lib\plugin, which holds the plugin DLLs. Now lib folders are allowed through, and *.lib files are still dropped by /XF. The apache list keeps lib in /XD as before, because stage\apache\lib only holds APR and httpd .lib files used at build time and nothing needed at run time.
The remaining options are /R:1 /W:1, which retries once after waiting one second if a file cannot be copied, and /NFL /NDL /NJH /NJS /NP, which suppresses file listings. robocopy exit codes from 0 to 7 count as success (they indicate what was copied); 8 and above indicate an actual failure. That is why the script checks -ge 8.
After the loop for those three components there are three new things.
templates\andtools\are mirrored toC:\APM\templatesandC:\APM\toolswith robocopy/MIRas well (a code of 8 or more fails with[ERR] copy templates failed). The point is thatC:\APMcarries the config templates andmkcert.phpwith it, so you can render config again or issue more certificates later without depending on the build folder.LICENSEis copied asC:\APM\LICENSE.txt. The build scripts distributed in the kit are under MIT with the Commons Clause (the full file is in the kit asLICENSE). The Apache, PHP, and MariaDB programs that come out of the build keep their own upstream licenses.- Apache's data files (
mime.types,magic,charset.conv, and so on) are copied as before, excludinghttpd.confso it does not overwrite your edits.
Section 2: Create working folders
www\default, logs\{apache,php,mariadb}, tmp\{sessions,uploads,opcache}, ssl, data: if these already exist, New-Item -Force does no harm. The new one is tmp\opcache, where PHP keeps OPcache's file cache (see the ASLR bug below). One detail relevant to the PidFile bug is that there is no apache\logs in this list. Apache's log folder is at C:\APM\logs\apache instead.
Section 3: Render config files from templates
The Render function reads files from templates\, substitutes the placeholders, then writes them to the destination. There are six now: {{APM_ROOT}}, {{HTTP_PORT}}, {{HTTPS_PORT}}, {{DB_PORT}}, and two new ones, {{BIND}} and {{CACHE_ID}}. The key point is that if the destination already exists and you have not passed -ResetConfig, it prints [KEEP] and skips it. Your edited files are safe. The consequence to remember is that if I change a template in a newer kit release, an already deployed config file will not automatically receive the change (the PidFile, FcgidWrapper, and OPcache bugs below are examples). A later -Lan also does not change the Listen line of an existing httpd.conf; it must be paired with -ResetConfig.
APM_ROOTisC:/APM(forward slashes) for Apache and MariaDB, which accept/, whilephp.iniusesC:\APMwith backslashes (-WinPaths).BINDis$Bindfrom the header. It is used in theListenlines ofhttpd.confandapm-ssl.confonly. MariaDB'smy.inidoes not use it and is fixed atbind-address = 127.0.0.1.CACHE_IDis a name for OPcache's cache, unique per install folder. The script takes the install path (backslashes turned into/, lower-cased), computes a SHA1, takes the first 4 bytes as 8 hexadecimal digits and putsapmin front, giving an 11-character name. The same path always gives the same name, and a different path gives a different one.- Files are written as plain ascii, without a BOM or an added trailing newline.
Section 4: Mozilla CA bundle
This downloads cacert.pem from curl.se into ssl\cacert.pem and checks its sha256 against the value published on the same site. If they do not match, it deletes the file and runs throw '[ERR] cacert.pem sha256 mismatch'. This file lets PHP's cURL and OpenSSL trust external websites' certificates (configured in php.ini under curl.cainfo and openssl.cafile). This runs only when the file does not exist or -ResetConfig is specified.
Section 5: Local CA and localhost certificate
APM does not include openssl.exe, so I wrote tools\mkcert.php to issue certificates using the openssl extension in the PHP build I just produced. The script calls APM's own php.exe with -n (do not load php.ini because deployment is not finished) and -d extension=openssl to load only what it needs. The value of -d extension_dir= is now wrapped in an extra layer of quotes (see the line & "$Target\php\php.exe" -n ... in the script). The reason is in the bracket-path bug below.
The condition for this section changed. The first release issued a certificate only when localhost.crt did not exist. This version issues one when the file is missing, or -ResetConfig is given, or -Lan is given. The reason is that the certificate's SAN must match how the site is opened. If you open it by machine name or a LAN IP and the certificate only has localhost, other machines' browsers will complain. The names included are localhost, 127.0.0.1, ::1, and with -Lan also the machine name (lower case) and the IPs in $LanIPs.
What mkcert.php does:
- Creates a CA named
APM Local Dev CA, valid for 10 years, only once (ifapm-ca.keyandapm-ca.crtalready exist, it reuses them). So issuing a new server certificate does not make a browser that already trusts the CA import anything again. - Issues a server certificate,
localhost.crt(the file name comes from the first host), valid for 825 days, with the whole list above as SANs. The key is EC (the prime256v1 curve). - Uses an entirely self-written OpenSSL config file, because I initially borrowed
openssl.cnffrom deps and encounteredError loading extension section v3_ca. That file did not have the required section. - New: checks at the start that the openssl extension really loaded. If not, it prints
[ERR] PHP openssl extension not loaded (check -d extension_dir="..." - quote it, folder names may contain ( ) or spaces)and exits with code 1. This message is the result of the bracket-path bug. - When the certificate approaches expiry (825 days), the easiest way now is
deploy.cmd -ResetConfig. It issues a new certificate from the same CA (but also overwrites any config you edited). Deletinglocalhost.crtandlocalhost.keyand running a plaindeploy.cmdalso works. I infer this from the script's logic; I have not tested it through an actual certificate expiry.
Sections 6 and 7: Home page and MariaDB datadir
This copies templates\index.php to www\default\index.php if it does not exist. The page changed slightly in the new version (see the templates section below). Then, if data\mysql\mysql (the system tables) does not exist, it runs mariadb-install-db.exe --datadir=... --port=... --config=<my.ini> --silent. This is a bootstrap operation that does not open a port, and it creates a root@localhost user without a password, like XAMPP.
A common pitfall: mariadb-install-db copies my.ini to data\mysql\my.ini, and that is the file the server actually reads at runtime (as used in my startup command below). C:\APM\mariadb\my.ini is only the initial template. The first release of the script never touched the file in the datadir again after creating it. This version adds an elseif ($ResetConfig) branch: if the datadir already exists and you pass -ResetConfig, the script copies C:\APM\mariadb\my.ini (just rendered) over data\mysql\my.ini and prints [OK] C:\APM\data\mysql\my.ini (synced). As a result, changing -DbPort with -ResetConfig really works. One thing to watch: if you edited data\mysql\my.ini by hand, the sync overwrites your edit.
Section 8: Check syntax and print a summary
httpd.exe -t only reads the config; it does not open any ports. If a directive is wrong or a module cannot load, it fails here before you start the server. After that the new version does two more things.
- It reads the
Listenline from thehttpd.confthat is actually in use (not the value it just computed) and printshttpd.conf Listen: <value>, so you can see where Apache will listen. The summary line above it changes fromdb :3307todb 127.0.0.1:3307because MariaDB always listens there. - If
-Lanwas given and thehttpd.confthat was kept with[KEEP]still starts with127.0.0.1:, it prints[WARN] httpd.conf was kept and still binds 127.0.0.1 - add -ResetConfig to switch it to LAN, then prints a URL for every LAN IP and reminds you to runscripts\firewall-lan.ps1as Administrator at least once.
What changed in templates\ in the new kit
All the bugs I fixed in this round ended up in four template files. I show only the lines that changed. The rest of each file is as described in Parts 3, 4, and 6.
httpd.conf: PidFile, and a Listen you can switch
Define APM "{{APM_ROOT}}"
ServerRoot "${APM}/apache"
# Default is <ServerRoot>/logs/httpd.pid, but deploy keeps logs in ${APM}/logs -> without this line
# httpd exits right after start with AH00099 (could not create pid file)
PidFile "${APM}/logs/apache/httpd.pid"
Listen {{BIND}}:{{HTTP_PORT}}
PidFile moved to the top with an explanatory comment (the first release had it at the bottom near ErrorLog). Listen used to be hard-coded as 127.0.0.1: and is now {{BIND}}:. The default is therefore still 127.0.0.1:8080 as before, and 0.0.0.0:8080 with -Lan.
apm-ssl.conf: the HTTPS Listen
Listen {{BIND}}:{{HTTPS_PORT}}
Same principle. This is the third line of the file. If you edited only httpd.conf and not this file, HTTP would be open to the LAN while HTTPS stayed on 127.0.0.1. -ResetConfig renders both files together, so there is nothing to worry about.
httpd.conf: FcgidWrapper with nested quotes
# 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
The first release had a plain FcgidWrapper "${APM}/php/php-cgi.exe" .php, which works when the path has no spaces (C:\APM has none). The full story is in the bug section below.
php.ini: the added OPcache block
; Windows: processes with the same cache_id share one OPcache segment. Two APM folders running at once
; (or any other PHP of the same build) would collide -> "Opcode handlers are unusable due to ASLR" and
; every request 500s. A per-folder id keeps them apart; the file cache is the fallback if mapping fails.
opcache.cache_id = "{{CACHE_ID}}"
opcache.file_cache = "{{APM_ROOT}}\tmp\opcache"
opcache.file_cache_fallback = 1
These three lines (with their comment) follow opcache.jit_buffer_size in the [opcache] section. The rest of the block (memory_consumption = 512, jit = tracing, and so on) is unchanged.
index.php: phpinfo from this machine only
// APM default page. Shows what is running; phpinfo() with ?info=1 (this machine only - APM may be on the LAN)
$isLocal = in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true);
if (isset($_GET['info']) && $isLocal) { phpinfo(); exit; }
And at the bottom of the page there is a link that appears only when $isLocal is true.
<?php if ($isLocal): ?><p><a href="?info=1">phpinfo()</a></p><?php endif; ?>
The reason is that phpinfo() reveals the machine's paths, the list of extensions, and every config value, none of which other machines on the LAN should see once -Lan is on. Another machine requesting ?info=1 gets the normal page instead (I tested this; the result is in the LAN section).
A note about the file: index.php is copied to www\default only when no file exists there. If you deployed earlier, you keep the old home page. To get the new one, copy it over yourself: Copy-Item C:\APM\templates\index.php C:\APM\www\default\index.php -Force (the source is in C:\APM\templates because the new deploy mirrors it there).
Run deploy.cmd and inspect the result
Step 5: Run deploy
In pwsh at C:\apm-build, you need the .\ prefix (PowerShell does not run files from the current folder unless explicitly told to).
.\deploy.cmd
The script finishes quickly and prints status lines. I did not time this step. If you need different ports, append the options, such as .\deploy.cmd -HttpPort 8081. Opening APM to other machines on the LAN (-Lan) has its own section below; this step is local only.
Step 6: Read the expected output (first deployment)
[OK] apache -> C:\APM\apache
[OK] php -> C:\APM\php
[OK] mariadb -> C:\APM\mariadb
[OK] C:\APM\apache\conf\httpd.conf
[OK] C:\APM\apache\conf\extra\apm-ssl.conf
[OK] C:\APM\php\php.ini
[OK] C:\APM\mariadb\my.ini
[OK] C:\APM\ssl\cacert.pem
[OK] new CA: C:\APM\ssl/apm-ca.crt
[OK] cert C:\APM\ssl/localhost.crt SAN=DNS:localhost,IP:127.0.0.1,IP:::1
[OK] datadir C:\APM\data\mysql (root@localhost, no password)
Syntax OK
[OK] APM deployed to C:\APM (http :8080, https :8443, db 127.0.0.1:3307) - not started
httpd.conf Listen: 127.0.0.1:8080
This block is the real output I recorded from the first release of deploy, adjusted in three places to match the new code: the SAN now has IP:::1 added, the summary says db 127.0.0.1:3307, and the last line httpd.conf Listen: is new. I have not yet deployed again with this version on a clean machine to compare it character by character. If your lines differ slightly, go by their meaning.
Note: the CA path appears as C:\APM\ssl/apm-ca.crt, with a / mixed in. This is not an error, just how mkcert.php joins strings. The summary line must always say not started, because the script does not start anything, and httpd.conf Listen: must be 127.0.0.1:8080 as long as you do not use -Lan.
Step 7: Check the resulting folders
Get-ChildItem C:\APM -Name
The output should include these names (the order may vary slightly).
apache
data
LICENSE.txt
logs
mariadb
php
ssl
templates
tmp
tools
versions.json
www
The actual layout and sizes from my machine (the sizes were measured on the first release; the new items do not change the total in any way I could notice, and I did not measure again):
C:\APM\
apache\ 20 MB bin\ modules\ conf\ error\ icons\
php\ 124 MB php.exe php-cgi.exe ext\ + deps and ImageMagick DLLs, php.ini
mariadb\ 201 MB bin\ lib\plugin\ share\ my.ini
data\ 302 MB mysql\ (datadir + the my.ini the server really reads)
ssl\ apm-ca.crt/.key, localhost.crt/.key, cacert.pem
www\default\ index.php
templates\ tools\ config templates and mkcert.php (copied from the kit)
logs\ tmp\ tmp\ holds sessions\ uploads\ opcache\
LICENSE.txt MIT + Commons Clause for the build scripts
versions.json the versions currently installed
Check one more thing: that the MariaDB plugins arrived (this is the bug the first release got wrong).
Get-ChildItem C:\APM\mariadb\lib\plugin -Filter *.dll | Measure-Object | Select-Object -ExpandProperty Count
The expected result is a number greater than 0. I did not record the exact number of files. If the command says the path does not exist, or you get 0, you are still on the old kit; see the bug section below.
What you see when deploying again
Running deploy again is how you upgrade. The difference from the first run is that config lines become [KEEP], and there are no CA, cert, or datadir lines because those already exist (based on the script logic described above).
[OK] apache -> C:\APM\apache
[OK] php -> C:\APM\php
[OK] mariadb -> C:\APM\mariadb
[KEEP] C:\APM\apache\conf\httpd.conf
[KEEP] C:\APM\apache\conf\extra\apm-ssl.conf
[KEEP] C:\APM\php\php.ini
[KEEP] C:\APM\mariadb\my.ini
Syntax OK
[OK] APM deployed to C:\APM (http :8080, https :8443, db 127.0.0.1:3307) - not started
httpd.conf Listen: 127.0.0.1:8080
-ResetConfig rewrites httpd.conf, apm-ssl.conf, php.ini, and my.ini (in the mariadb folder) from templates, downloads cacert.pem again, issues a new server certificate (the CA stays), and copies the new my.ini over data\mysql\my.ini. Your database files are not touched. Before using it, save copies of any config files you have edited, including data\mysql\my.ini if you ever edited it by hand. Changing -DbPort later now takes a single command, .\deploy.cmd -DbPort 3306 -ResetConfig (stop APM first). This conclusion comes from reading the script; I have not tested a full port change after deployment.
Make the browser trust the CA, issue more certificates, and set PATH
The certificate for https://localhost:8443 is issued by a CA created on your own machine. The browser does not trust it yet, so it shows a warning until you import this CA. Do this once per user. There are two ways: a one-line script (my recommendation) or clicking through Windows yourself. The result is the same.
Step 8: The shortcut with trust-ca.ps1 (recommended)
Open a regular pwsh window (no admin needed) and run it from the kit folder, because scripts\ is not copied to C:\APM.
pwsh -NoProfile -File C:\apm-build\scripts\trust-ca.ps1
The script reads C:\APM\ssl\apm-ca.crt (if it is missing it stops with [ERR] C:\APM\ssl\apm-ca.crt missing - run deploy.cmd first), opens the Trusted Root Certification Authorities store of the current user, and adds the CA if that thumbprint is not already there. Windows pops up a security warning asking you to confirm once; click Yes, just like the manual route in Step 12 below. On success it prints this shape (the subject, thumbprint, and expiry belong to the CA created on your machine):
[OK] trusted: <CA subject> (<thumbprint>), valid until <yyyy-MM-dd>
Running it again prints [OK] already trusted: .... To stop trusting the CA, add -Remove, which prints [OK] removed ... (or [OK] not trusted - nothing to remove). -Target points to an install folder other than C:\APM.
Check that the CA is in the store:
Get-ChildItem Cert:\CurrentUser\Root | Where-Object Subject -like '*APM Local Dev CA*'
The expected result is one row whose thumbprint matches what the script printed. Then close and reopen the browser and go to https://localhost:8443/ while Apache is running (see Step 18). There must be no warning.
A note on Firefox: according to the comment in the script, Firefox reads the Windows store through security.enterprise_roots, which is on by default in current Firefox builds. I did not test with Firefox myself. If it still warns, import the CA in Firefox's own Settings separately.
Other machines on the LAN: copy only apm-ca.crt to that machine (never apm-ca.key) and import it by double-clicking, following Steps 9 through 12 below.
Alternative: import it by hand (Steps 9 through 12)
If you would rather not run a script, or you want to see what Windows does, use these steps instead.
Step 9: Open the CA file
In File Explorer, go to C:\APM\ssl and double-click apm-ca.crt. A certificate details window appears, with the issuer named APM Local Dev CA.
Step 10: Start the installation
Click Install Certificate..., select Current User, then click Next.
Step 11: Select the correct store
Select Place all certificates in the following store, click Browse, select Trusted Root Certification Authorities, click OK, then Next and Finish.
Step 12: Confirm the Windows warning
Windows asks whether you really want to install this CA. Click Yes. The final window reports that the import succeeded.
The expected result is that, once Apache is running, opening https://localhost:8443/ in a browser (Edge or Chrome) shows no certificate warning. I verified this step with a test that bypasses certificate verification (curl -k) in the next section, but I did not record the result from a browser that already trusted the CA.
C:\APM\ssl\apm-ca.key is a key that can issue certificates for any website your browser trusts. Do not copy it off the machine, do not share the ssl folder, and do not install apm-ca.crt on someone else's machine without telling them.
Step 13: Issue extra certificates for vhosts with new-cert.ps1
If you run several sites on one machine and want each to have its own HTTPS, you can issue more certificates from the same CA. There is no new CA to import.
pwsh -NoProfile -File C:\apm-build\scripts\new-cert.ps1 -Hosts myapp.localhost
The script calls the same mkcert.php that deploy uses, through the php.exe in C:\APM. You can give several names separated by commas, such as -Hosts myapp.localhost,myapp.test; the first name is used for the file name. The result is C:\APM\ssl\myapp.localhost.crt and .key, and a status line as mkcert.php is written:
[OK] cert C:\APM\ssl/myapp.localhost.crt SAN=DNS:myapp.localhost
Names ending in .localhost are the most convenient, because Chrome, Edge, and Firefox resolve them to 127.0.0.1 themselves without editing the hosts file. Other names, such as .test, need a line in C:\Windows\System32\drivers\etc\hosts that you add yourself. If you reuse a name, the .crt and .key files for that name are overwritten.
To use them in a vhost, create a new file in C:\APM\apache\conf\vhosts (httpd.conf already has IncludeOptional conf/vhosts/*.conf). I wrote the example below following the pattern of apm-ssl.conf and have not tested it. The two SSLCertificate... lines come from the comment in the script.
<VirtualHost *:8443>
ServerName myapp.localhost
DocumentRoot "${APM}/www/myapp"
SSLEngine on
SSLCertificateFile "${APM}/ssl/myapp.localhost.crt"
SSLCertificateKeyFile "${APM}/ssl/myapp.localhost.key"
</VirtualHost>
Always run C:\APM\apache\bin\httpd.exe -t before starting.
Step 14: Put php and mariadb on PATH with set-path.ps1
If you want to type php -v or mariadb from anywhere (and have VS Code, composer, and artisan find them), this script puts APM's folders on PATH. There are two levels.
pwsh -NoProfile -File C:\apm-build\scripts\set-path.ps1 -Scope User
-Scope User needs no admin and changes only your own PATH. Without the option it changes the machine-wide PATH, which needs a pwsh window run as Administrator (otherwise it stops with [ERR] Machine scope needs Administrator (or use -Scope User)). The script does three things: it removes C:\xampp\... entries from PATH, puts C:\APM\php and C:\APM\mariadb\bin where the first XAMPP entry used to be (so precedence against other tools does not change), and tells Windows the environment changed. It prints:
[OK] User PATH updated (REG_EXPAND_SZ)
- <entries that were removed>
+ C:\APM\php
+ C:\APM\mariadb\bin
restart VS Code / terminals to pick it up
A warning: if you still run XAMPP alongside, this script also takes XAMPP's php off PATH. Use -Remove to take APM's entries out again. Programs that are already open (VS Code, terminals) must be closed and reopened to see the change.
Check that it works in a new pwsh window:
php -v
The first line should be PHP 8.4.27 (cli) ... or whichever version you built.
What I got wrong with PATH: why the script edits the registry directly
At first I changed PATH with [Environment]::SetEnvironmentVariable. It turned out to expand %SystemRoot% into C:\WINDOWS and write the value back as REG_SZ, permanently. PATH in the registry should be of type REG_EXPAND_SZ, which stores raw text such as %SystemRoot%\system32 and lets Windows expand it at use time. Once it becomes a plain-text REG_SZ, those entries are replaced by fixed values for good and no longer follow the machine. Now set-path.ps1 opens the registry key directly, reads the value with the DoNotExpandEnvironmentNames option to get the raw text, edits only our entries, and writes it back as REG_EXPAND_SZ. If you write your own script that edits PATH, watch out for this.
To check the value type after running it (if you used -Scope User):
(Get-Item -Path HKCU:\Environment).GetValueKind('Path')
The expected result is ExpandString, which is REG_EXPAND_SZ. I took the type name from the .NET documentation and did not record real output from this command.
First start: MariaDB, Apache, testing, and shutdown
Everything in this section uses results I measured on 2026-10-22 on Windows 11 x64. I used a new pwsh window (regular user), in any folder, because every command uses a full path.
Step 15: Start MariaDB
Start-Process C:\APM\mariadb\bin\mariadbd.exe -ArgumentList '--defaults-file=C:\APM\data\mysql\my.ini'
The command returns to the prompt immediately without printing anything. A mariadbd console window may appear. Do not close that window, because closing it kills the server. Use --defaults-file to point to my.ini in the datadir, as explained in Section 7.
Step 16: Wait for MariaDB to be ready
Start-Sleep 6
Six seconds was enough for a newly created datadir on my machine. If your machine is slower, wait longer before continuing.
Step 17: Query the version and collation
C:\APM\mariadb\bin\mariadb.exe -uroot --port=3307 --protocol=tcp -e "select version(), @@collation_server, @@have_ssl, @@bind_address"
Expected output (actual result):
version() @@collation_server @@have_ssl @@bind_address
11.8.9-MariaDB-log utf8mb4_unicode_ci YES 127.0.0.1
Reading the columns in order: version 11.8.9, collation utf8mb4_unicode_ci as pinned in my.ini (Part 4 explains why), have_ssl is YES, meaning the wolfSSL bundled with the source works, and the bind address is 127.0.0.1 as intended.
Step 18: Start Apache
Start-Process C:\APM\apache\bin\httpd.exe
This also returns to the prompt immediately. If you use the latest kit, Apache should start normally. If it does not start and you previously deployed with an older template, skip ahead to the PidFile discussion below.
Step 19: Test HTTP
curl.exe -s -o NUL -w "%{http_code} %{content_type}`n" http://127.0.0.1:8080/
Expected output:
200 text/html; charset=UTF-8
200 means Apache responded, and text/html; charset=UTF-8 from index.php means PHP is also working through mod_fcgid (this is not a static file).
Step 20: Test HTTPS
curl.exe -sk -o NUL -w "%{http_code}`n" https://localhost:8443/
Expected output:
200
-k tells curl to skip certificate verification, so this result only confirms that TLS works and Apache responds. It does not confirm that the CA is trusted. If you have imported the CA and want to check without bypassing verification, remove -k. You should get 200 as well, but I did not record the result of that run.
Step 21: Open the home page in a browser
Open http://127.0.0.1:8080/ in a browser. You will see a short table that I (as the author of this page) designed to show what is running.
| Row | Value I saw |
|---|---|
| Server | Apache (ServerTokens Prod removes the version number) |
| Protocol | HTTP/1.1, with TLS appended when accessed over https |
| PHP | 8.4.27 cgi-fcgi, OPcache JIT on |
| MariaDB | The MariaDB version number if it is running, or not reachable (...) if it has not been started |
| Extensions | A list of loaded extensions, including igbinary redis imagick gd intl mbstring opcache |
The phpinfo() link under the table appears only when you open the page from the APM machine itself (127.0.0.1 or ::1). If the MariaDB row says not reachable even though you started it, see the troubleshooting table at the end. The home page connects as user root without a password, on the port specified by mysqli.default_port in php.ini. If you later set a password for root, this row will report that it cannot connect, which is expected behavior.
Step 22: View phpinfo
Open http://127.0.0.1:8080/?info=1 for the full phpinfo() page (the new index.php allows this only from the APM machine, via 127.0.0.1 or ::1; any other machine requesting ?info=1 gets the normal page). Use it to check that the loaded ini file belongs to APM and to inspect extension details. The response headers Apache sends contain only Server: Apache, without a version number.
What I encountered when starting Apache for the first time
During my first test, I ran Start-Process httpd.exe, then got no response from curl on port 8080. Checking the processes showed that httpd died immediately after startup. There was no error window to see, because Start-Process hid everything.
I ran C:\APM\apache\bin\httpd.exe directly in the console and saw this line.
AH00099: could not create C:/APM/apache/logs/httpd.pid
The cause was in Section 2 of the deploy script, which I pointed out above. Apache uses the default PidFile value, logs/httpd.pid, relative to ServerRoot (C:/APM/apache). But the deploy script creates the log folder at C:\APM\logs\apache, not C:\APM\apache\logs. Apache therefore could not write its pid file and refused to start. I did not check whether this line was also written to error.log; I only definitely saw it in the console.
The fix I added to the kit was to explicitly tell Apache in templates\httpd.conf to put its pid file in the log folder that actually exists.
PidFile "${APM}/logs/apache/httpd.pid"
ErrorLog "${APM}/logs/apache/error.log"
The kit you download already includes this line. But if you previously deployed with the old template, httpd.conf in C:\APM was preserved by [KEEP] and did not receive the change. Check and fix it with these steps.
Step 23: Check whether your file already has a PidFile line
Get-Content C:\APM\apache\conf\httpd.conf | Select-String -Pattern '^PidFile'
If this line appears, it is already fixed and you do not need to continue.
PidFile "${APM}/logs/apache/httpd.pid"
If there is no output, choose one of the fixes in the next two steps.
Step 24 (option A): Add the line yourself
Open the file in Notepad.
notepad C:\APM\apache\conf\httpd.conf
Find ErrorLog "${APM}/logs/apache/error.log" and add PidFile "${APM}/logs/apache/httpd.pid" above it. Save the file, then check the syntax.
C:\APM\apache\bin\httpd.exe -t
Expected output:
Syntax OK
Step 25 (option B): Have deploy render the config files again
This is easier but overwrites all four config files you may have edited. Back them up first if you have made changes. Then run this at C:\apm-build (only when no APM processes are running).
.\deploy.cmd -ResetConfig
When finished, return to Step 18 (start Apache).
Shut everything down properly
Step 26: Stop Apache
Apache started with Start-Process has no window in which to press Ctrl+C, so you must stop it with a command (if you ran httpd.exe in your own console, you can simply use Ctrl+C in that window).
Get-Process httpd | Where-Object Path -like 'C:\APM\*' | Stop-Process
This command prints nothing on success. It filters by Path to avoid killing another program's httpd, such as a running XAMPP instance. Finding more than one process is normal for Apache on Windows (there is a parent and a child). This command stops them all.
Step 27: Stop any remaining php-cgi processes
Get-Process php-cgi -ErrorAction SilentlyContinue | Where-Object Path -like 'C:\APM\*' | Stop-Process
mod_fcgid creates php-cgi.exe processes to wait for work. They may remain after Apache dies. If there are no processes to stop, this command prints nothing.
Step 28: Shut down MariaDB properly
C:\APM\mariadb\bin\mariadb-admin.exe -uroot --port=3307 --protocol=tcp shutdown
This command prints nothing on success and lets MariaDB flush its data to disk before exiting, which is better than killing the process. The mariadbd console window closes on its own.
Step 29: Confirm that nothing remains running
Get-Process httpd,php-cgi,mariadbd -ErrorAction SilentlyContinue | Where-Object { $_.Path -like 'C:\APM\*' }
The expected result is no output. APM is now fully stopped and ready for the next deploy.cmd run.
This series covers building and deploying. I will write a separate article later about the controller for start, stop, and installation as a Windows Service (apmctl). There are no apmctl commands available yet. For now, use the manual startup and shutdown procedures above.
Letting other machines on the LAN in
APM's default is to listen on 127.0.0.1 only, so only the machine it is installed on can reach it. That fits a development machine, but sometimes I want to show a site I am building to a teammate in the same office, or open it from a phone on the same Wi-Fi. The new kit therefore has a -Lan switch that opens only Apache to the LAN and keeps MariaDB on 127.0.0.1 at all times.
With -Lan, every machine on the same subnet can open whatever is in C:\APM\www. If the sites you put there have admin pages or real data, anyone on the same network sees them. Do not use it on public networks (cafes, hotels) and do not port-forward from the router to the internet. APM is a development machine, not a production server.
There are five steps, done once APM is fully stopped (Step 29).
Step 30: Deploy again with -Lan and -ResetConfig
At C:\apm-build in a regular pwsh window:
.\deploy.cmd -Lan -ResetConfig
You need -ResetConfig too if you have deployed before, because the existing httpd.conf and apm-ssl.conf were kept with [KEEP] and still say Listen 127.0.0.1:.... If you forget, the script warns with [WARN] httpd.conf was kept and still binds 127.0.0.1 - add -ResetConfig to switch it to LAN. On a first install with no config yet, -Lan alone is enough. It combines with the port options. When I tested, I used deploy.cmd -HttpPort 80 -HttpsPort 443 -DbPort 3306 -Lan -ResetConfig (standard ports, because the test machine had no XAMPP). If XAMPP is still running on yours, keep this series' default ports as written above.
A warning: -ResetConfig overwrites all four config files you may have edited, as noted in the repeat-deployment section.
Step 31: Read the output to confirm Listen changed
Three things differ from a local deploy. I show the shape the script prints (the machine name and IP are examples).
[OK] cert C:\APM\ssl/localhost.crt SAN=DNS:localhost,IP:127.0.0.1,IP:::1,DNS:pc-name,IP:192.168.10.x
[OK] C:\APM\data\mysql\my.ini (synced)
Syntax OK
[OK] APM deployed to C:\APM (http :8080, https :8443, db 127.0.0.1:3307) - not started
httpd.conf Listen: 0.0.0.0:8080
LAN: http://192.168.10.x:8080/ https://192.168.10.x:8443/
Firewall: run scripts\firewall-lan.ps1 once as Administrator
First, the certificate's SAN has the machine name and LAN IP added. Second, Listen is 0.0.0.0:8080. Third, there is one LAN: line per IP the machine has (you will see two if it has both a cable and Wi-Fi), plus the firewall reminder. If your IP changes later (DHCP hands out a new one), the certificate will not cover the new IP, so run this step again.
Step 32: Open the firewall once with firewall-lan.ps1
Windows Firewall blocks inbound traffic by default, so -Lan alone is not enough; you need a rule for httpd.exe. This step needs administrator rights. Right-click pwsh, choose Run as administrator, and run:
pwsh -NoProfile -File C:\apm-build\scripts\firewall-lan.ps1
The expected output (ports as you deployed them):
[OK] firewall rule 'APM Apache (LAN)': TCP 8080,8443 from LocalSubnet -> C:\APM\apache\bin\httpd.exe
If you are not running as admin it stops with [ERR] run as Administrator. The rule it creates is deliberately narrow:
- Inbound TCP only
- Tied to the single program
C:\APM\apache\bin\httpd.exe, not an open port - Accepts only
LocalSubnet, meaning machines on the same subnet, not the whole internet - Applies to every network profile (
Any), because home Wi-Fi is sometimes classified as Public by Windows - The ports are read from the actual
Listenlines inhttpd.confandapm-ssl.conf, not guessed from defaults; if it finds none it stops with[ERR] no Listen ports found in ...
You can run it again. It deletes any existing rule with the same name first and then creates a new one, so rules never pile up.
Step 33: Start APM and test from another machine
Start MariaDB and Apache with Steps 15 through 18 on the APM machine. Then, on another machine on the same subnet (the examples use 192.168.10.x in place of the APM machine's real IP), try these two commands:
curl.exe -s -o NUL -w "%{http_code}`n" http://192.168.10.x:8080/
curl.exe -s -o NUL -w "%{http_code}`n" http://192.168.10.x:8080/server-status
The first should return 200; the second should return 403, because /server-status has Require local in httpd.conf. The results I measured from another machine on the LAN, after deploy.cmd -Lan -ResetConfig and opening the firewall rule (with ports 80, 443, and 3306 as described in Step 30):
- http returned 200 and https returned 200
?info=1returned the normal page, not phpinfo (phpinfo is only visible from the APM machine itself)/server-statusreturned 403- MariaDB's port 3306 was closed and could not be reached
All as intended. To open HTTPS from another machine without a warning, you also have to import apm-ca.crt on that machine first, as described in the certificate section, and open the site by a name or IP that is in the SAN.
Step 34: Turn LAN access off again
Stop APM (Steps 26 through 29), then do two things. First, remove the firewall rule in an admin pwsh window:
pwsh -NoProfile -File C:\apm-build\scripts\firewall-lan.ps1 -Remove
The expected result is [OK] removed firewall rule 'APM Apache (LAN)'. Second, render the config back to local at C:\apm-build in a regular window:
.\deploy.cmd -ResetConfig
Without -Lan, $Bind goes back to 127.0.0.1. Confirm from the last line that it says httpd.conf Listen: 127.0.0.1:8080. I read this step from the code and have not rehearsed the reversal.
Why MariaDB never opens to the LAN
I made -Lan touch only Apache on purpose, because the root account of MariaDB in APM has no password (like XAMPP). That is only safe because it listens on 127.0.0.1 alone. If the database were exposed to the LAN, anyone on the same network could connect as root immediately. Three layers keep it closed:
my.iniis fixed. The linebind-address = 127.0.0.1in the template does not use the{{BIND}}variable, so the value does not change whether or not you pass-Lan(and deploy printsdb 127.0.0.1:3307in the summary line every time).- The firewall rule is tied to
httpd.exealone. Even if someone changed MariaDB's bind address by hand, Windows Firewall has no rule lettingmariadbd.exeaccept inbound connections. - The ports in the rule come from Apache's
Listenlines only, so there is no way for 3307 to slip into the rule by accident.
If you ever really need to reach the database from another machine, use an SSH tunnel or create a dedicated user with a password and set the bind yourself. Do not open it through -Lan.
Bugs found after the series went out, and what the new kit fixes
After this part first went out, I kept testing the finished build, extracting it into unusual paths and running two APM folders at the same time. I found five more bugs that made me change the scripts and templates (the PidFile bug in the first-start section is one more that was fixed in the same round). For each one I describe the symptom, the message you see, the cause, and the fix. Where I did not keep the real error text, I say what to check instead of guessing the message.
The kit released at first contains all five bugs below. Download it again from the contents page (the new file is 48,142 bytes, sha256 starting with 24BA604D), extract it over C:\apm-build, and run .\deploy.cmd -ResetConfig. If you have never edited the config in C:\APM, that is all it takes. If you have edited it and do not want it overwritten, just run .\deploy.cmd (the new binaries, including MariaDB's plugins, will arrive) and copy the changed lines into your config by hand. There are three places: PidFile and FcgidWrapper in httpd.conf, and the opcache.cache_id block in php.ini (examples are in the templates section above; for cache_id any name not used by another folder will do, such as apmmain).
Bug 1: MariaDB plugins missing after deploy
Symptom: Every deploy line passes, but the MariaDB plugin DLLs are missing from C:\APM, so anything that needs a plugin (such as the ARCHIVE engine or ed25519 login) cannot work. I did not keep MariaDB's error text, so check the files instead.
Get-ChildItem C:\APM\mariadb\lib\plugin -Filter *.dll -ErrorAction SilentlyContinue | Select-Object -First 5 Name
If nothing is printed, the folder is missing or empty. Compare it with the source at C:\apm-build\stage\mariadb\lib\plugin, which must contain DLLs such as ha_archive and auth_ed25519.
Cause: The robocopy exclusion list for MariaDB had lib in /XD. I put it there to drop the top-level lib folder that only holds developer .lib files, but /XD lib skips every folder named lib at every level, including lib\plugin, which holds the plugin DLLs needed at run time.
Fix: Remove lib from MariaDB's /XD. *.lib files are already dropped by /XF.
# before
mariadb = @('/XD', 'include', 'lib', 'symbols', 'sql-bench', '/XF', '*.pdb', '*.lib', '*.exp', 'my.ini')
# after (with a comment in the script)
mariadb = @('/XD', 'include', 'symbols', 'sql-bench', '/XF', '*.pdb', '*.lib', '*.exp', 'my.ini')
If you have already deployed, download the new kit and run .\deploy.cmd again. You do not need -ResetConfig, because robocopy will copy the missing plugin folder without touching my.ini or your data.
Bug 2: Changing the database port with -ResetConfig has no effect
Symptom: You deploy again with .\deploy.cmd -DbPort 3306 -ResetConfig and start MariaDB as in Step 15. The port is still 3307, but APM's home page connects to port 3306 (because php.ini was rendered again and now has mysqli.default_port = 3306), so the page says not reachable. You can check with:
Select-String -Path C:\APM\mariadb\my.ini, C:\APM\data\mysql\my.ini -Pattern '^port'
On the old version the two files give different port numbers (3306 in the first, 3307 in the second). I inferred this result from the code and did not keep the real output when I hit it.
Cause: The server does not read C:\APM\mariadb\my.ini. It reads the copy that mariadb-install-db placed in data\mysql\my.ini when it first created the datadir, and the old version never touched that file again. -ResetConfig rendered the file in the mariadb folder, which the server does not read.
Fix: Add an elseif ($ResetConfig) branch after the datadir section that copies mariadb\my.ini over data\mysql\my.ini and prints [OK] C:\APM\data\mysql\my.ini (synced) (the full code is in the script quoted above). The old text in this part that told you to edit data\mysql\my.ini by hand is therefore obsolete.
Bug 3: Two APM folders running together, every PHP page returns 500
Symptom: I extracted the packaged build into a second folder to test it while C:\APM was still running. Every PHP page answered 500. Apache's error.log (logs\apache\error.log) had this message:
End of script output before headers
When I ran php-cgi.exe -v from the new folder directly, I got the message that names the real cause:
Opcode handlers are unusable due to ASLR
Cause: On Windows, OPcache shares memory by name. When two folders run the same PHP build as the same user, they fight over the same block of memory. The block was laid out at an address that does not match the second process (ASLR randomizes addresses differently), so the later process cannot use the stored opcodes and dies before it sends headers. If I had not tried running the two side by side, I would never have found this.
Fix: Give each install folder its own cache name, and turn on the file cache as a fallback. These are the three php.ini lines in the templates section above (opcache.cache_id, opcache.file_cache, opcache.file_cache_fallback = 1). I let 90-deploy.ps1 compute the {{CACHE_ID}} value from the path, so two folders never share a name and nobody has to invent one, and deploy creates tmp\opcache for you.
One caveat: two folders running at the same time still need different Apache and MariaDB ports (-HttpPort, -HttpsPort, -DbPort), because cache_id only fixes the cache.
Bug 4: Install path contains a space, Apache says Wrapper cannot be accessed
Symptom: I extracted the build into a folder whose name has a space (the example is D:\APM test) and deployed there. Apache complained in error.log with a message containing:
Wrapper D:/APM cannot be accessed
Notice that the path in the message is cut at the space, leaving only D:/APM.
Cause: mod_fcgid splits the FcgidWrapper command line on spaces (according to the comment in the file, with apr_tokenize_to_argv). The outer quotes of the directive in httpd.conf are removed by Apache before the value reaches the module, so the module gets a bare path containing a space and cuts it in two.
Fix: Put quotes inside, escaped with \", so the module sees another pair of quotes around the path.
FcgidWrapper "\"${APM}/php/php-cgi.exe\"" .php
The standard C:\APM path has no spaces, so most people never hit this bug. It appears when you use -Target "D:\APM test" or extract under a folder whose name has a space.
Bug 5: Install path contains brackets, certificate fails with Undefined constant OPENSSL_KEYTYPE_EC
Symptom: You deploy to a folder whose name has brackets, such as D:\APM (copy), and the certificate step fails. The message comes from mkcert.php's PHP, not from the deploy script:
Undefined constant OPENSSL_KEYTYPE_EC
followed by [ERR] certificate generation failed from 90-deploy.ps1.
Cause: The script passes the extension path to PHP as php -d extension_dir=<path>, but the value of -d is read with INI syntax, where parentheses ( ) mean something special. The value gets mangled, PHP cannot find the extension folder, and openssl does not load. When mkcert.php then refers to the constant OPENSSL_KEYTYPE_EC, which exists only if openssl is loaded, PHP complains that the constant does not exist. The message does not point at the real cause, which is that openssl failed to load.
Fix: Two places. First, 90-deploy.ps1 wraps the value in another layer of quotes:
& "$Target\php\php.exe" -n -d "extension_dir=`"$Target\php\ext`"" -d extension=openssl `
"$Root\tools\mkcert.php" "$Target\ssl" @certHosts
Second, mkcert.php checks up front that openssl is really loaded and, if not, states the cause plainly:
if (!extension_loaded('openssl')) {
fwrite(STDERR, "[ERR] PHP openssl extension not loaded (check -d extension_dir=\"...\" - quote it, folder names may contain ( ) or spaces)\n");
exit(1);
}
Bugs 4 and 5 share a root: I used to test only in C:\APM, where every path is clean. Now you can deploy somewhere else, with odd paths, and it works.
Update when there is a security release
This is why I wrote the whole series. Every version and hash is in one file, versions.json. When a patch is announced, I edit that file, fetch only the components that changed, rebuild only those components, and run deploy.cmd again.
The steps below are based on the scripts I read and a repeat build of the entire set without changing versions (the full cycle took 10 minutes: fetch 0.0, vcpkg 0.1, apache 1.6, mariadb 0.3, imagemagick 3.3, php 5.0). I have not completed a full cycle with an actual version change, because there is no next release to switch to yet. The parts that changed in the new kit (`-Only` splitting commas itself, and PECL being re-copied every run) I also read from the code. The version numbers in the examples below are therefore hypothetical. Whenever I describe what a script does, that comes from reading its code.
Choose the right component to build
Security news usually identifies which program contains the vulnerability. Use this table to decide what to edit and which scripts to run (from the HOWTO section on "Updating when there is a security patch" that I compiled, with additional details I found while reading the scripts).
| Patched component | What to edit | What to run |
|---|---|---|
| PHP | sources.php (version, url, sha256) |
00-fetch.ps1 -Only php, then 30-php.ps1 -Step all |
| Apache httpd / APR | sources.httpd or sources.apr sources.apr-util |
00-fetch.ps1 -Only httpd, then 20-apache.ps1 |
| MariaDB | sources.mariadb |
00-fetch.ps1 -Only mariadb, then 40-mariadb.ps1 |
| redis, igbinary, imagick (PECL) | sources.pecl-*; also delete that entry's line in versions.lock.json (no need to delete build\php-pecl any more; the script re-copies every run) |
00-fetch.ps1 -Only pecl-redis (use the appropriate name), then 30-php.ps1 -Step build |
| Apache's OpenSSL | ports-overlay\openssl\vcpkg.json (version) and portfile.cmake (SHA512) |
10-deps-vcpkg.ps1, then 20-apache.ps1 |
| Other Apache libraries | git -C toolchain\vcpkg pull, then change builtin-baseline in deps\apache\vcpkg.json to the new commit |
10-deps-vcpkg.ps1, then 20-apache.ps1 |
| ImageMagick | sources.imagemagick.version (git tag) |
25-imagemagick.ps1, then 30-php.ps1 -Step build |
| DLLs from PHP deps | No file edits needed | 30-php.ps1 -Step all (phpsdk_deps -u fetches the latest versions automatically) |
| Move to PHP 8.5 | sources.php and php_deps_series to "8.5" |
Only when the production machine moves |
One key detail: OpenSSL on the PHP side comes from the PHP team's deps package (3.0.22 in this build). It is separate from Apache's OpenSSL (3.5.9). If OpenSSL releases a patch, check which side you need to update. For Apache, edit the overlay yourself. For PHP, wait for the PHP team to release a new deps package, then 30-php.ps1 -Step all will fetch it.
Below I use PHP as the main example. The Apache and MariaDB procedures are exactly the same, differing only in the entry and script names.
Step 35: Open a terminal and stop APM
Open a regular pwsh window, then stop everything using Steps 26 through 29 in the first-start section. If APM is not running, skip this step.
Step 36: Back up the current version (optional, but I recommend it)
deploy.cmd overwrites the existing binaries with /MIR, leaving no old version to roll back to. I therefore recommend copying the component you are about to change first. For PHP, for example:
robocopy C:\APM\php C:\APM-prev\php /MIR /NFL /NDL /NJH /NJS /NP
This command prints nothing, and robocopy returns exit code 1 after a successful copy (it is not an error). Check that the destination folder contains files.
Test-Path C:\APM-prev\php\php-cgi.exe
True
If you are updating MariaDB, also back up the databases with a dump first (you must start MariaDB first, then shut it down as before when finished; I did not try this command during the test run).
C:\APM\mariadb\bin\mariadb-dump.exe -uroot --port=3307 --protocol=tcp --all-databases --result-file=C:\APM-prev\all.sql
Step 37: Go to the build folder and back up versions.json
cd C:\apm-build
Copy-Item versions.json versions.json.bak
There is no output. The .bak file is for comparison or recovery if you make a mistake.
Step 38: Find the new version number and sha256 for each component
Where to get each value (you need all three for each entry: version, url, sha256):
- PHP: Open
https://www.php.net/releases/index.php?json&version=8.4&max=1in a browser, inspect the latest release's file list, findphp-<version>.tar.xz, and copy that file's sha256. Theurlishttps://www.php.net/distributions/php-<version>.tar.xz. - httpd and APR: Use the
.sha256file next to the tarball on downloads.apache.org. You can fetch the value with pwsh (assuming httpd 2.4.70). Take only the first field, which is the hash.
((curl.exe -fsSL https://downloads.apache.org/httpd/httpd-2.4.70.tar.bz2.sha256) -split '\s+')[0]
The expected output is one line containing a 64-character hexadecimal string (if curl returns 404, that version has moved to archive.apache.org or you mistyped the version number).
- MariaDB: See
https://downloads.mariadb.org/rest-api/mariadb/11.8/latest/, which lists hashes for each source file in the latest release. Theurlfollows the patternhttps://archive.mariadb.org/mariadb-<version>/source/mariadb-<version>.tar.gz. - If you cannot find the hash on the upstream site: Download the file manually, then compare it against the release announcement using
Get-FileHash <file> -Algorithm SHA256. Do not use a value from a third-party site.
Step 39: Edit versions.json
Open it in Notepad (or your usual editor).
notepad C:\apm-build\versions.json
Find the php entry under sources and change the three values on the same line. This hypothetical example assumes PHP releases 8.4.28 (before and after; I shortened the hashes in the article, but the actual values must contain all 64 characters).
Before editing:
"php": { "version": "8.4.27", "url": "https://www.php.net/distributions/php-8.4.27.tar.xz", "sha256": "dcf93923b0e3...d3f0" },
After editing:
"php": { "version": "8.4.28", "url": "https://www.php.net/distributions/php-8.4.28.tar.xz", "sha256": "<sha256 of php-8.4.28.tar.xz from php.net>" },
Change all three fields. Do not change only version, because url and sha256 do not update automatically. Do not leave sha256 as null for entries whose upstream publishes a hash (I deliberately keep PHP, httpd, APR, and MariaDB pinned to hashes at all times).
Step 40: Check that the JSON is still valid
Get-Content C:\apm-build\versions.json -Raw | ConvertFrom-Json | Out-Null
The expected result is no output at all. If red text appears, the file is malformed (usually a missing comma or quote). Return to Step 39 and fix it. If you cannot fix it, copy versions.json.bak back and start again.
Step 41: Handle entries that use the lock file (only when changing versions for entries without sha256)
Entries such as pecl-redis, pecl-igbinary, pecl-imagick, mod_fcgid, and toolchain entries with "sha256": null in versions.json use trust-on-first-use. 00-fetch.ps1 records the initial hash in versions.lock.json and compares against that value on subsequent runs. When you change one of these entries' versions, its new hash will not match the old hash in the lock file, and the script stops with [ERR] sha256 mismatch. Delete that entry's line from versions.lock.json first, for example the "pecl-redis": "..." line (watch the comma on the final line so the JSON remains valid).
Part 6 used to warn you to delete the build\php-pecl\<name> folder by hand, because the first release of the script copied PECL there only once and the folder name carries no version, so after you changed the version the build quietly kept using the old source. The new kit no longer needs that step. 30-php.ps1 deletes build\php-pecl\<name> and copies src\pecl-<name>-<version> again on every run, and if the source for the version you named has not been fetched, it stops with [ERR] <path> missing - run 00-fetch.ps1, so do Step 42 first. The line in versions.lock.json still has to be deleted by hand as before, because 00-fetch.ps1 still compares hashes against the lock. Skip this step when updating only PHP, httpd, APR, or MariaDB, because those 4 already have hashes in versions.json. My explanation of the lock and the re-copy comes from reading 00-fetch.ps1 and 30-php.ps1; I have not tested an actual PECL version change.
Step 42: Fetch new source only for the changed component
pwsh -NoProfile -File scripts\00-fetch.ps1 -Only php
Expected output (the version matches your edit; this is a hypothetical example):
[GET] php 8.4.28
[OK] php -> C:\apm-build\src\php-8.4.28
The download is saved in downloads\ and extracted into src\php-8.4.28. The old source folder (src\php-8.4.27) remains and is not deleted. If the sha256 does not match the value you entered in versions.json, the script immediately runs throw, displaying both the actual and expected values. Do not force it through. First check that you copied the hash for the correct component and file. If you really need to download and extract it again, append -Force to the command.
If updating several components at once, put the names in one comma-separated list.
pwsh -NoProfile -File scripts\00-fetch.ps1 -Only php,httpd
The expected output is a block for each, in order (hypothetical version numbers as before):
[GET] php 8.4.28
[OK] php -> C:\apm-build\src\php-8.4.28
[GET] httpd 2.4.70
[OK] httpd -> C:\apm-build\src\httpd-2.4.70
This works because pwsh -File passes php,httpd as a single string, and the new 00-fetch.ps1 splits it on commas itself with the line below, which sits under the param line. (The first release did not have this line, so it downloaded nothing and showed no error; see the original story in Part 2. If you still have the old kit, download the new one.) The line that was added is:
$Only = @($Only | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })
Step 43: Build only the changed component
For PHP:
pwsh -NoProfile -File scripts\30-php.ps1 -Step all
Measured rebuild times for individual components were php 5.0 minutes and apache 1.6 minutes (the first build of each took longer: php 12, apache 4, mariadb 30 minutes). See Part 6 for details on reading PHP's output. For Apache, use 20-apache.ps1, and for MariaDB, use 40-mariadb.ps1, as listed in the table above.
If you are unsure which components to build, running the entire .\build.cmd is always valid. With the cache still present, it takes approximately 10 minutes based on my measurements. The new build.cmd also has a -To option to go with -From, for example .\build.cmd -From php -To php, which stops after that step (the steps are fetch vcpkg apache mariadb imagemagick php).
Step 44: Check the version in stage before deploying
C:\apm-build\stage\php\php.exe -v
The first line must show the new version in the same format as Part 6 (such as PHP 8.4.28 (cli) ... for the hypothetical example). If it still shows the old version, the script is still reading the old versions.json or the build failed. Revisit Steps 39 and 43. For Apache, check with C:\apm-build\stage\apache\bin\httpd.exe -V; for MariaDB, use C:\apm-build\stage\mariadb\bin\mariadbd.exe --version.
Step 45: Deploy over the existing installation
At C:\apm-build, confirm that APM is stopped (Step 35), then run:
.\deploy.cmd
The expected output is three [OK] lines for the binaries, followed by four [KEEP] lines for the config files, Syntax OK, the summary line containing not started, and the httpd.conf Listen: line, as shown in the repeat-deployment section.
Step 46: Start and confirm the new version
Start using Steps 15 through 18, then check two places. First, check the installed binary's version.
C:\APM\php\php.exe -v
Second, open http://127.0.0.1:8080/ in a browser. The PHP row must show the new version, and the curl.exe commands in Steps 19 and 20 must both return 200. Once these checks pass, you can delete the backup with Remove-Item C:\APM-prev -Recurse -Force (delete it only when you are confident).
Step 47: Roll back if the new version has problems
Stop APM (Steps 26 through 29), then copy the backup back while protecting php.ini from being overwritten.
robocopy C:\APM-prev\php C:\APM\php /MIR /XF php.ini /NFL /NDL /NJH /NJS /NP
There is no output. Check with C:\APM\php\php.exe -v; it must show the old version again. Then start it again. I designed this rollback procedure myself and have not rehearsed it.
A successful build and deployment do not mean the vulnerability is fixed. Always compare the version number against the upstream security advisory (PHP 8.4.27 or later in this case). Do not run an internet-facing website on APM with its default settings, because APM is designed to listen on 127.0.0.1 and serve as a development machine, not a production server.
Troubleshooting table for the whole series
The first table lists 17 build-related issues (from the HOWTO troubleshooting notes, including the Visual Studio row added after lib.ps1 changed). I have included them all, even those covered in other parts, so they can be found in one place. The final column identifies the part with the details. Anything marked "already in the script" has already been fixed in the script; you do not need to do anything.
| Symptom | Cause | Fix | Part |
|---|---|---|---|
[ERR] Visual Studio 2022 with C++ x64 tools not found |
lib.ps1 uses vswhere to look for a VS 2022 that has the VC.Tools.x86.x64 component and finds none (the C++ workload is not installed, or only other VS versions are) |
Install VS 2022 or Build Tools with that component, as in Part 1. Any edition, any install location | 1 |
'buildconf.bat' is not recognized |
The environment sets NoDefaultCurrentDirectoryInExePath=1, so cmd does not search the current folder |
Call .\buildconf.bat / .\configure.bat (already in the script) |
6 |
configure does not recognize --enable-gd |
Windows flag names differ from Linux | Use --with-gd, --with-dom, and check with -Step help |
6 |
| GD has no WebP/AVIF, PCRE JIT does not work | --disable-all disables options that are normally enabled |
Add --with-libwebp --with-libavif --with-pcre-jit --enable-redis-session |
6 |
| Changing configure does not change the result | nmake reuses existing .obj files | Delete build\php-obj and stage\php before every build |
6 |
igbinary/igbinary.h: No such file |
redis includes the header with the igbinary path prefix | --with-extra-includes=build\php-pecl |
6 |
php.exe does not run (exit 53) |
Dependency DLLs are not next to the exe | Find them with dumpbin /dependents, then copy them |
6 |
Compatibility with CMake < 3.5 has been removed |
APR/httpd CMake files are old | -DCMAKE_POLICY_VERSION_MINIMUM=3.5 |
3 |
Unexpected enablement value "N" for mod_lua |
httpd's CMake accepts only O/i/I/a/A, and the old value remains in the cache | Omit ENABLE_LUA and use cmake --fresh |
3 |
| mod_lua is built with Lua 5.5 | FindLua51 picks up vcpkg's newer Lua |
Remove lua from the manifest (the production machine does not use mod_lua either) | 3 |
mod_md.so requires libcurl-d.dll |
The vcpkg imported target has no RelWithDebInfo config, so it falls back to Debug | -DCMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO=Release;RelWithDebInfo; |
3 |
conf\mime.types is missing |
httpd's cmake --install does not install conf |
Copy it from the source's docs\conf |
3 |
ImageMagick was build with a 64 channel bit mask and that requires a C++ compiler |
ImageMagick's Configure forces a 64 bit channel mask on x64, making the headers C++-only, but imagick is written in C | Change Options.cpp to use 32 bit, then rebuild ImageMagick |
5 |
| ImageMagick's Configure fails to build because v145 cannot be found | The project targets VS2026 | /p:PlatformToolset=v143 and run Configure with /VS2022 |
5 |
imagick does not load (Class "Imagick" not found) |
No extension = imagick in php.ini |
Already added to the template; if you deployed earlier, run deploy.cmd -ResetConfig |
6 and 7 |
Error loading extension section v3_ca when issuing a cert |
The openssl.cnf borrowed from deps lacks the required section | mkcert.php creates its own complete config file |
7 |
| Git's perl breaks OpenSSL | Git's perl is cygwin and generates Unix-style paths | Use only Strawberry Perl in toolchain\ |
2 and 4 |
The second table covers deployment, startup, LAN, and update issues, some of which are actual error messages printed by 90-deploy.ps1.
| Symptom | Cause | Fix |
|---|---|---|
[ERR] httpd from C:\APM is running - stop APM before deploying (or mariadbd, php-cgi) |
An APM process is running; Windows cannot overwrite files in use | Stop it using Steps 26 through 29, then deploy again |
[ERR] service APM-Apache is running - stop APM before deploying (or APM-MariaDB) |
A Windows service from the ready-made APM is running. A service's process cannot be seen by Get-Process, which is why there is a separate check |
Stop the service with Stop-Service (needs admin), then deploy again |
[ERR] ...\stage\apache missing - build first |
That component has not been built, or its build failed | Go back and finish Part 3, 4, or 6 |
[ERR] copy templates failed (or tools) |
The robocopy of that folder returned 8 or more (a destination file is locked, or permissions are insufficient) | Close any program that has files open under C:\APM\templates or tools (such as an editor), then deploy again |
[ERR] cacert.pem sha256 mismatch |
The downloaded CA bundle does not match the hash published by curl.se (an incomplete download or an intervening proxy) | Run deploy.cmd again; if it recurs, check the network and proxy |
[ERR] certificate generation failed |
mkcert.php failed to issue the certificate; OpenSSL or PHP messages appear below this line |
Read the messages that follow and check that C:\APM\php\ext\php_openssl.dll exists. If you see Undefined constant OPENSSL_KEYTYPE_EC, see the next row |
Undefined constant OPENSSL_KEYTYPE_EC or [ERR] PHP openssl extension not loaded when issuing a cert |
The install path has brackets or spaces, so -d extension_dir= is mangled and openssl does not load |
Use the new kit, which quotes the value, or move to a path with no special characters (Bug 5) |
[ERR] mariadb-install-db failed |
The datadir could not be created | Check whether C:\APM\data\mysql is a partially created folder; if so, delete it and deploy again (delete only if it contains no important data yet) |
[ERR] httpd -t failed |
The config has a syntax error or a module cannot load | Read the preceding output from httpd -t and edit the specified file |
httpd.exe dies immediately after startup, AH00099: could not create C:/APM/apache/logs/httpd.pid |
The old template has no PidFile, and apache\logs does not exist |
Follow Steps 23 through 25 |
Every PHP page returns 500, error.log says End of script output before headers, php-cgi.exe -v says Opcode handlers are unusable due to ASLR |
Another APM (or another PHP of the same build) is running from a different folder and fighting for the same OPcache memory | Use the new kit, which sets opcache.cache_id per folder; if fixing by hand, add the three opcache lines to php.ini (Bug 3) |
Apache says Wrapper D:/APM cannot be accessed (or another path cut at a space) |
The install path contains a space and mod_fcgid splits the command at the space |
Change it to FcgidWrapper "\"${APM}/php/php-cgi.exe\"" .php, or deploy with the new kit (Bug 4) |
MariaDB plugins (ha_archive, auth_ed25519) are missing from C:\APM\mariadb\lib\plugin |
The old kit had lib in robocopy's /XD, which dropped lib\plugin too |
Download the new kit and run .\deploy.cmd again (Bug 1) |
After changing -DbPort with -ResetConfig, the real port is still the old number and the home page says not reachable |
The server reads data\mysql\my.ini, which the old kit did not sync |
Use the new kit (-ResetConfig syncs it and prints (synced)), or edit the port line in data\mysql\my.ini by hand (Bug 2) |
| Apache starts, but the website is inaccessible | Another program is using the port, or Apache did not start | Check ports with Get-NetTCPConnection -LocalPort 8080,8443 and confirm there is an actual httpd process from C:\APM; change ports with -HttpPort / -HttpsPort and -ResetConfig |
The home page shows MariaDB not reachable (...) |
MariaDB has not been started, its port does not match mysqli.default_port in php.ini, or root now has a password |
Start it using Step 15 and check Step 17; if you set a root password, this page reports that it cannot connect, as expected |
| Another machine cannot open the site over the LAN (timeout) | httpd.conf still listens on 127.0.0.1 (deploy warned [WARN] httpd.conf was kept ...), or the firewall rule has not been created, or the machines are on different subnets |
Check that the httpd.conf Listen: line says 0.0.0.0:...; if not, run deploy.cmd -Lan -ResetConfig. Run scripts\firewall-lan.ps1 as admin, and make sure both machines are on the same subnet (the rule accepts LocalSubnet only) |
| Another machine opens HTTPS and the browser warns about the certificate | That machine does not trust apm-ca.crt, or you opened a name/IP that is not in the SAN (the IP changed after deploy) |
Import apm-ca.crt on that machine; if the IP changed, run deploy.cmd -Lan -ResetConfig to issue a new certificate |
?info=1 shows the normal page, no phpinfo, and no phpinfo() link |
You opened it from another machine. phpinfo is allowed for 127.0.0.1 and ::1 only |
This is intended. Open it from the APM machine itself |
The browser warns that the https://localhost:8443 certificate is untrusted |
The local CA has not been imported (or Firefox is not reading the Windows store) | Run trust-ca.ps1 (Step 8) or follow Steps 9 through 12 |
00-fetch.ps1 stops with sha256 mismatch after changing a PECL or mod_fcgid version |
The new hash does not match the old hash in versions.lock.json |
Delete that entry's line from the lock file as described in Step 41 |
00-fetch.ps1 -Only php,httpd downloads nothing and shows no error (old kit) |
pwsh -File passes the list as one string, and the old script did not split on commas |
Download the new kit; the script now splits commas itself. Or run it through -Command |
[ERR] ...\src\pecl-redis-<version> missing - run 00-fetch.ps1 when building PHP |
You changed the PECL version in versions.json but have not fetched it yet |
Run pwsh -NoProfile -File scripts\00-fetch.ps1 -Only pecl-redis (the name of the entry you changed), then build again |
| Changing a PECL version still produces the old extension (old kit) | The old PHP script copied PECL into build\php-pecl only when the folder did not exist |
Use the new kit, which deletes and re-copies every run, or delete build\php-pecl\<name> yourself |
The rows about lib\plugin, the database port, paths with spaces or brackets, and ASLR are symptoms I hit while testing the kit. Where I did not copy the error text from a real log, I say what to check instead. The rows about the service check, copy templates, LAN, the lock file's sha256 mismatch, and PECL I derived from reading the scripts; I have not met them myself. The other rows are messages or symptoms that occurred during testing, or messages written verbatim in the scripts.
Checklist to close the series
Before considering your APM ready to use, check all seven items.
.\deploy.cmdends with[OK] APM deployed to C:\APM ... - not started, preceded bySyntax OK, with a linehttpd.conf Listen: 127.0.0.1:8080(or0.0.0.0:8080if you meant to open the LAN)C:\APM\sslcontainsapm-ca.crt,apm-ca.key,localhost.crt,localhost.key, andcacert.pemC:\APMhasLICENSE.txt,templates,tools, andtmp\opcache, andC:\APM\mariadb\lib\plugincontains.dllfiles- Step 17 returns
11.8.9-MariaDB-logand collationutf8mb4_unicode_ci - Step 19 returns
200 text/html; charset=UTF-8, and Step 20 returns200 - The home page shows
8.4.27 cgi-fcgi, OPcache JIT onand the MariaDB version number (when MariaDB is running) - Step 29 prints nothing, meaning everything has stopped cleanly, and you have
versions.json.bakor a copy of a workingversions.jsonas the starting point for your next update
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 6: Build PHP · Next: None yet (the apmctl controller will have a separate article)
Personally, I think the value of this series lies more in the update procedure than in the initial build. You build it for the first time only once. The next time a new CVE appears, you edit one file and wait ten-odd minutes. I designed versions.json from the start to be the file I would touch most often, and I will write about the apmctl controller separately once it actually works. I will come back and add that 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