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

We recently launched our [new Status Page feature](/feature/status-pages). Under the hood, it's using the [Caddy proxy server](https://caddyserver.com/) and [Laravel's subdomain routing](https://laravel.com/docs/5.8/routing#route-group-sub-domain-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](https://github.com/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.

```nginx
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](https://laravel.com/docs/5.8/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.

```nginx
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](https://laravel.com/docs/5.8/routing#route-group-sub-domain-routing).

Our `RouteServiceProvider.php` contains something similar to this.

```php
$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.

```php
$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.

```php
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.

```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.

```apache
<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](/register) to monitor your first site and publish your first status page. 