Goal

Allow customers to use their own custom domains for tracking (e.g. track.customer.com) by only adding a single CNAME record, while you handle:

  • automatic SSL certificates (Let’s Encrypt)
  • no CloudFront required
  • no customer certificates
  • works with any DNS provider (Cloudflare, Route53, GoDaddy, etc.)
  • scalable and secure

High-Level Architecture

                    Customer Domain
track.customer.com
      |
      |  CNAME
      v
tracking.yourservice.com
      |
      v
Caddy (Edge / ACME Broker)
- terminates TLS
- auto-issues SSL
- forwards traffic
      |
      v
Apache (WHM / cPanel, port 8080)
      |
      v
Laravel Application

                  

Key idea:
TLS terminates at Caddy, not Apache.
Apache only serves one known vhost, regardless of customer domain.

Requirements

  • VPS with WHM / cPanel (AlmaLinux 9.x)
  • Laravel application
  • Root access
  • A public tracking entry domain (e.g. tracking.yourservice.com)
  • Customers only add one CNAME

Step 1 — Move Apache off ports 80 / 443

Caddy must own ports 80 and 443.

In WHM:

 Tweak Settings ->System(tab)->change:
Apache SSL port->8080

Apache non-SSL IP/port->8443

Then rebuild and restart Apache:

                    /scripts/rebuildhttpdconf
/scripts/restartsrv_httpd
                  

Verify:

                    ss -lntp | egrep ':80|:443|:8080'
                  

Expected:

  • Caddy → :80, :443
  • Apache → :8080

Step 2 — Install Caddy on AlmaLinux

                    dnf install -y dnf-plugins-core
dnf install -y 'dnf-command(copr)'
dnf copr enable @caddy/caddy
dnf install -y caddy

systemctl enable --now caddy

                  

Open firewall:

                    firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload

                  

Step 3 — Why Apache Must NOT See Customer Domains

cPanel generates Apache virtual hosts only for domains it knows.

If Apache receives:

                    Host: track.customer.com

                  

…it will fall back to the default vhost (cPanel default page).

Correct solution

  • Always send one known Host header to Apache
  • Pass the real customer domain via X-Forwarded-Host

This avoids:

  • creating vhosts dynamically
  • wildcard ServerAlias hacks
  • cPanel conflicts

Step 4 — Final Caddyfile (Production-Ready)

                    {
    email [email protected]
    storage file_system /var/lib/caddy

    on_demand_tls {
        ask http://127.0.0.1/__tls/allow-domain
    }
}

:80 {
    # Permission check for on-demand certificates
    handle /__tls/allow-domain {
        reverse_proxy ORIGIN_IP:8080 {
            header_up Host app.yourservice.com
            header_up X-Forwarded-Proto http
        }
    }

    handle_path /.well-known/acme-challenge/* {
        file_server
    }

    handle {
        redir https://{host}{uri} permanent
    }
}

:443 {
    tls {
        on_demand
    }

    reverse_proxy ORIGIN_IP:8080 {
        # Force Apache to always use the known Laravel vhost
        header_up Host app.yourservice.com

        # Preserve the real customer domain
        header_up X-Forwarded-Host {host}
        header_up X-Forwarded-Proto https
    }
}

                  

Replace:

  • ORIGIN_IP → your server’s public IP
  • app.yourservice.com → a domain that already exists in cPanel and points to Laravel

Validate and reload:

                    caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy

                  

Step 5 — Implement the On-Demand TLS Permission Endpoint

Caddy must not issue certificates for arbitrary domains.

We implement a permission check (ask) that calls Laravel.

Laravel route

                    use Illuminate\Http\Request;

Route::get('/__tls/allow-domain', function (Request $request) {
    $domain = strtolower(trim($request->query('domain', '')));

    if ($domain === '') {
        return response('DENY', 403);
    }

    // Example DB check
    $allowed = \App\Models\CustomerDomain::where('domain', $domain)
        ->where('active', 1)
        ->exists();

    return $allowed
        ? response('OK', 200)
        : response('DENY', 403);
});

                  

Now:

  • customer adds domain in your UI
  • you mark it active in DB
  • first HTTPS request triggers SSL issuance
  • no extra DNS records needed

Step 6 — Laravel: Accept Any Custom Domain

Do not restrict routes to your own domain.

❌ Don’t do this

                    Route::domain('{subdomain}.yourservice.com')->group(...)

                  

✅ Do this instead

                    Route::match(['get','post'], '/', [TrackingController::class, 'track']);

                  

Inside the controller:

                    $host = request()->header('X-Forwarded-Host') ?? request()->getHost();
// Example: track.customer.com

                  

Map $host to the correct customer.

Step 7 — Trust the Reverse Proxy (Important)

Tell Laravel it’s behind Caddy.

app/Http/Middleware/TrustProxies.php

                    protected $proxies = '*';

protected $headers =
    \Illuminate\Http\Request::HEADER_X_FORWARDED_FOR |
    \Illuminate\Http\Request::HEADER_X_FORWARDED_HOST |
    \Illuminate\Http\Request::HEADER_X_FORWARDED_PORT |
    \Illuminate\Http\Request::HEADER_X_FORWARDED_PROTO;

                  

Clear caches:

                    php artisan optimize:clear

                  

Step 8 — Customer Onboarding Flow (Final UX)

Your customers only do one thing:

                    track.customer.com  CNAME  tracking.yourservice.com

                  

That’s it.

No certificates
No CloudFront
No DNS validation records
No proxy requirements

SSL is issued automatically on first HTTPS request.

Step 9 — Recommended Hardening

  • Make tracking endpoints stateless (no sessions)
  • Disable CSRF on tracking routes
  • Rate-limit /__tls/allow-domain
  • Reject wildcard domains
  • Store issued domains in DB with status