During migration, the highest risk is validating the new server only after DNS switch. In this implementation, I used a PHP smart proxy to compare legacy and new backends side by side, without public DNS changes and without per-machine hosts edits.
1) Real migration problem I needed to solve
Production URL was already live, but the new server could not receive public traffic yet. I needed to:
- test the same domain behavior on the target backend;
- keep public DNS unchanged during validation;
- avoid per-device
hostsfile edits; - compare legacy vs target in the same validation window.
This is where the smart proxy matters: connect to target IP, but send Host: domain.com so Apache resolves the correct VirtualHost.
2) Architecture used in production
config.php: target server IPs and portsdev.php: side-by-side comparison UI with two iframesproxy.php: request engine with host spoofing and response adaptation
The proxy connects to target IPs, but sends the requested domain in Host header so Apache serves the correct virtual host.
3) Full implementation with operational code
3.1 config.php
<?php
const ANDAMENTO_IP = '10.10.10.11';
const FORTIS_IP = '10.10.10.12';
const DEFAULT_HTTP_PORT = 80;
const DEFAULT_HTTPS_PORT = 443;
3.2 dev.php (comparison panel)
<?php
$uri = $_GET['uri'] ?? 'mydomain.com/';
$uri = trim($uri);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Migration comparator</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; }
.bar { padding: 12px; border-bottom: 1px solid #ddd; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; height: calc(100vh - 70px); }
iframe { width: 100%; height: 100%; border: 0; }
input { width: 70%; padding: 8px; }
button { padding: 8px 12px; }
</style>
</head>
<body>
<div class="bar">
<form method="get">
<label>URI (domain + path):</label>
<input name="uri" value="<?= htmlspecialchars($uri, ENT_QUOTES, 'UTF-8') ?>">
<button type="submit">Compare</button>
</form>
</div>
<div class="grid">
<iframe src="proxy.php?server=andamento&uri=<?= urlencode($uri) ?>"></iframe>
<iframe src="proxy.php?server=fortis&uri=<?= urlencode($uri) ?>"></iframe>
</div>
</body>
</html>
3.3 proxy.php (field version)
<?php
require 'config.php';
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
$server = $_GET['server'] ?? '';
$uri = $_GET['uri'] ?? '';
$servers = [
'andamento' => ['ip' => ANDAMENTO_IP, 'http_port' => DEFAULT_HTTP_PORT, 'https_port' => DEFAULT_HTTPS_PORT],
'fortis' => ['ip' => FORTIS_IP, 'http_port' => DEFAULT_HTTP_PORT, 'https_port' => DEFAULT_HTTPS_PORT],
];
if (!isset($servers[$server]) || !preg_match('/^[\w\-\.\/\?\%\=\&\#\:]+$/', $uri)) {
http_response_code(400);
die('Invalid parameters');
}
$srv = $servers[$server];
$uri = trim($uri);
if (strpos($uri, '://') === false) {
$uri = 'http://' . $uri;
}
$parsed = parse_url($uri);
$domain = $parsed['host'] ?? '';
$path = $parsed['path'] ?? '/';
if (!empty($parsed['query'])) {
$path .= '?' . $parsed['query'];
}
if ($domain === '') {
http_response_code(400);
die('Invalid domain');
}
if ($path === '') {
$path = '/';
}
function executeCurl(string $url, string $domain): array
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_USERAGENT => 'migration-proxy/1.0',
CURLOPT_HTTPHEADER => [
'Host: ' . $domain,
'Accept: */*',
'Accept-Encoding: gzip, deflate',
'Connection: keep-alive',
'Cache-Control: no-cache',
'Pragma: no-cache',
],
CURLOPT_ENCODING => 'gzip, deflate',
CURLOPT_HEADER => false,
]);
$response = curl_exec($ch);
$error = curl_error($ch);
$errno = curl_errno($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'text/html';
curl_close($ch);
return [
'response' => $response,
'error' => $error,
'errno' => $errno,
'code' => $code,
'type' => $type,
];
}
$target = 'http://' . $srv['ip'] . ':' . $srv['http_port'] . $path;
$result = executeCurl($target, $domain);
if ($result['errno'] !== 0 || $result['code'] >= 400) {
$target = 'https://' . $srv['ip'] . ':' . $srv['https_port'] . $path;
$result = executeCurl($target, $domain);
}
if ($result['response'] === false || $result['response'] === null) {
http_response_code(502);
header('Content-Type: text/plain; charset=utf-8');
echo "Proxy failed to fetch target\n";
echo "target: {$target}\n";
echo "curl_errno: {$result['errno']}\n";
echo "curl_error: {$result['error']}\n";
exit;
}
$response = $result['response'];
$contentType = $result['type'];
if (stripos($contentType, 'text/plain') !== false) {
header('Content-Type: text/plain; charset=utf-8');
echo $response;
exit;
}
$base = 'http://' . $srv['ip'] . '/';
$response = str_replace('<head>', '<head><base href="' . $base . '">', $response);
header('Content-Type: text/html; charset=utf-8');
echo $response;
4) Validation commands used during troubleshooting
# Validate Apache vhost map
apachectl -S
apachectl -t
# Validate host override against each backend
curl -sv "http://10.10.10.11/" -H "Host: mydomain.com" -o /dev/null
curl -sv "http://10.10.10.12/" -H "Host: mydomain.com" -o /dev/null
# Sentinel endpoint available only on target host
curl -sv "http://10.10.10.12/test.txt" -H "Host: mydomain.com"
5) Field issues and fixes
3.1 Default Apache page instead of app
Root cause: virtual host mismatch.
Fix: corrected ServerName/ServerAlias, validated with apachectl -S, and confirmed with direct curl -H "Host: ..." tests.
3.2 Broken CSS/JS/images
Root cause: relative assets still resolving through public DNS.
Fix: injected <base href="http://TARGET_IP/"> inside <head>.
3.3 Sentinel text endpoint rendered incorrectly
Root cause: plain text response being treated as HTML.
Fix: direct pass-through for Content-Type: text/plain.
6) Apache baseline required on target host
<VirtualHost *:80>
ServerName mydomain.com
ServerAlias www.mydomain.com
DocumentRoot /var/www/mydomain
<Directory /var/www/mydomain>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
Validation:
apachectl -S
apachectl -t
systemctl reload apache2
7) Security controls during migration window
- source IP restriction
- basic auth on comparison interface
- dedicated logs for migration requests
- strict server allowlist
- cURL timeout and redirect caps
SSL verification bypass was used only in controlled pre-production tests.
8) Pre-cutover runbook
- create sentinel endpoint on new host only
- validate side-by-side behavior
- validate auth/session/forms/uploads
- validate redirects and status codes
- fix deltas and retest
- perform DNS cutover only after parity
9) Outcome
The smart proxy removed workstation-level manual setup, accelerated QA, and reduced post-cutover incidents by moving validation to an auditable, repeatable, pre-DNS process.
This post is licensed under CC BY-NC.
Comments
Join the discussion below.
Comments are not configured yet. Add Cusdis settings in /assets/json/config/blog-comments-config.json.