Back to blog

How to build a smart proxy for side-by-side server validation during migration

3/2/2026 · 2 min · Infrastructure

Share

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:

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

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

SSL verification bypass was used only in controlled pre-production tests.

8) Pre-cutover runbook

  1. create sentinel endpoint on new host only
  2. validate side-by-side behavior
  3. validate auth/session/forms/uploads
  4. validate redirects and status codes
  5. fix deltas and retest
  6. 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.

CC BY-NC

This post is licensed under CC BY-NC.

Comments

Join the discussion below.