How we used Caddy and Laravel's subdomain routing to serve our status pages

We recently launched our new Status Page feature. Under the hood, it's using the Caddy proxy server and Laravel's subdomain routing to serve the right status page on the right domain.

With this technology stack, we can automatically generate, configure & renew the SSL certificates for custom domains of our clients.

In this post we'll deep dive in to our current setup.

Caddy to serve and manage all SSL/TLS certificates

Caddy is a powerful server that excels at configuring SSL/TLS certificates on-the-fly, when a user first connects to their domain. You configure Caddy through a single Caddyfile configuration file.

Our latest Caddyfile config can always be found in our repo ohdearapp/status.ohdear.app-Caddyfile. We'll describe the interesting bits in this post.

First, we want to make sure every domain automatically runs on HTTPS instead of the unsecure HTTP.

http:// {
  	redir https://{host}{uri}
}

This makes sure any connection attempt on http:// gets translated to https://. Caddy does this automatically too, but we want to be explicit and prevent our PHP code from first rewriting the domain before an HTTPS upgrade occurs.

Next up is the config that allows Caddy to handle the automatic certificate issuance.

https:// {

	tls {
		ask https://ohdear.app/caddy/allowed-domain
	}
	
	[...]
}

We catch every https:// URL that makes it to this server. The tls directive is then used to instruct Caddy to issue Let's Encrypt certificates automatically, in case it doesn't yet have a certificate.

Because we don't want to try to issue a certificate for every domain that connects to us (that might trigger our Let's Encrypt rate limits), we use Caddy's ask feature to callback to Oh Dear! and ask if that domain is allowed to be issued a certificate.

Caddy does this by firing a GET request to our URL. If that returns an HTTP/200, it's allowed to continue. The URLs look like this.

https://ohdear.app/caddy/allowed-domain?domain=status.ohdear.app
https://ohdear.app/caddy/allowed-domain?domain=status.dnsspy.io
... 

Because we don't really want to expose all our status page URLs to someone brute forcing that URL, HTTP calls are only allowed from the Caddy servers' IP, using a custom HTTP Middleware in Laravel.

Once the domain is allowed, we will proxy the result back to our main application, but modify the Host header ever so slightly.

https:// {
	[...]
	
	proxy / http://ohdear.app {
		# We use Laravel's subdomain routing to match
		# this domain to the right status page
		header_upstream Host {host}.status.ohdearapp.com
		
		# Confirm the request came from our Caddy proxy
		header_upstream StatusPageHost {host}

		# Add headers any proxy would expect
		header_upstream X-Real-IP {remote}
		header_upstream X-Forwarded-For {remote}
		header_upstream X-Forwarded-Port {server_port}
		header_upstream X-Forwarded-Proto {scheme}

		# We should never take more than 5s to load
		timeout 5s
	}
	
	[...]
}

By modifying the Host header that gets sent back to us, we can inject the domain name as a subdomain: header_upstream Host {host}.status.ohdearapp.com.

Our application therefore gets a request to status.dnsspy.io.status.ohdearapp.com.

At that point, it's up to Laravel to handle the request.

Subdomain routing in Laravel

To make this work, we use Laravel's subdomain routing.

Our RouteServiceProvider.php contains something similar to this.

$router->domain('{domain}.'. config('app.url'))->group(function () {
    require base_path('routes/status-pages.php');
});

The rest of our application (the public site, documentation, the dashboard etc.) get limited to only be served on the ohdear.app domain.

$router->group([
    'middleware' => ['web', 'hasTeam'],
    'domain' => config('app.url') ,
], function () {
    require base_path('routes/front.php');
});

Because domain names can contain periods or dashes, we modified the RouteServiceProvider.php to let Laravel receive the full domain name as a variable.

class RouteServiceProvider extends ServiceProvider
{
    public function map(Router $router)
    {
        Route::pattern('domain', '[a-z0-9.-]+');
    }
}

By default, it will only match [a-z0-9]+.

Our CustomSubdomainShowStatusPageController will then get the full domain name and use it to retrieve the details of the correct status page, and render it to our users.

A slightly modified webserver configuration

Because we're now receiving a set of unknown subdomains, we modified our Apache vhost to act as a catch-all vhost. In other words: any domain that points to this server, will hit our Oh Dear! application.

We do this by creating a new vhost and adding a ServerAlias of * in Apache.

<VirtualHost *:80>
  ServerName              status.ohdearapp.com
  ServerAlias             *
  
  [...]
  
  RewriteCond %{HTTP:StatusPageHost}  ^$
  RewriteRule ^ https://ohdear.app%{REQUEST_URI} [L,R=301]
</VirtualHost>

The RewriteRule at the bottom makes it so that any domain that hits this vhost, but didn't have the custom StatusPageHost header, will be redirected to our homepage.

(Note we're running Apache on port :80 with our own Nginx proxy serving TLS traffic on port :443.)

Forcing a single domain for Oh Dear! for SEO purposes

If you read the line "any domain that points to this server, will hit our Oh Dear! application" and you cringed a little, you might have thought about the SEO implications of doing such a thing. Well, we sure did.

Because of our modified routes-configuration, where we scope the status pages and our main application to a specific domain, we'll never accidentally show pages of our main app on the status pages.

In our main apache config, we have a hard-defined list of domains we want to serve.

<VirtualHost *:80>
  ServerName  ohdearapp.com
  ServerAlias www.ohdearapp.com ohdear-app.com www.ohdear-app.com ohdear.app www.ohdear.app
  
  [...]
  
  Options +FollowSymlinks
  RewriteEngine on
  RewriteCond %{THE_REQUEST} !\s/api/? [NC]
  RewriteCond %{HTTP_HOST}   !^ohdear\.app [NC]
  RewriteRule ^              https://ohdear.app%{REQUEST_URI} [L,R=301]
</VirtualHost>

Everything that gets processed by that vhost will trigger the rewrite rule at the bottom. In short: if you're accessing that vhost and your domain name isn't ohdear.app (but one of our aliases), we'll rewrite you to our main site.

Additionally, if you hit the HTTP version of our status pages on their subdomain (ie: status.dnsspy.io.status.ohdearapp.com), we consider that a failed request (it should have come from the Caddy proxy on status.dnsspy.io) and we'll redirect you to our main site.

We can test this with curl.

$ curl -I "http://94.176.99.159" \
    -H "Host: status.dnsspy.io.status.ohdearapp.com"
HTTP/1.1 301 Moved Permanently
Location: https://ohdear.app/

$ curl -I https://status.dnsspy.io
HTTP/2 200

$ curl -I https://ohdearapp.com
HTTP/1.1 301 Moved Permanently
Location: https://ohdear.app/

Good, SEO secured & status pages served!

Want to give our Status Pages a try?

We'd love it if you created your own status page and showed it to the world!

Go ahead and create your account to monitor your first site and publish your first status page.

More updates

Want to get started? We offer a no-strings-attached 10 day trial. No credit card required.

Start monitoring

You're all set in
less than a minute!