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