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.