We recently made Hyvor Blogs self-hostable. While working on it, I came up with an opinionated abstraction - a way to reason about TLS handling in self-hosted applications.
TLS Mode
The core principle is to introduce a TLS_MODE configuration that supports the following values:
auto: the application manages TLSexternal: a reverse proxy terminates TLSmanual: the user provides the certificatesdisabled: TLS is disabled; the application serves HTTP only
TLS Mode: auto
The application listens on port 443 and handles TLS by itself. It generates a certificate using a service like Let's Encrypt and renews it automatically. This can be handled using an auto-HTTPS server like Caddy.
I think it makes sense to keep this as the default option. There is less friction when getting started. The user doesn't have to think about generating TLS certificates.
However, to use Let's Encrypt (the ACME protocol), the domain must be public. So, this option by itself doesn't cover all use cases, but it makes your application easy to self-host.
If this is the default option, you will also have to accept the domain name as an input or environment variable. This made sense for Hyvor Blogs because it is already designed to work with multiple domains (custom domains of user blogs), and defining DOMAIN_APP was required.
TLS Mode: external
In this mode, the application runs behind a reverse proxy that terminates TLS. This covers a wide variety of use cases: routing within a private network, something like Kubernetes Ingress, Cloudflare Flexible SSL, and more.
The application itself would listen on port 80 or another port. The reverse proxy would terminate TLS, in which case it would handle certificate management and forward the requests to the application's HTTP port.
Here, a TRUSTED_PROXY configuration is very important. When running behind a reverse proxy, your application may need to know whether the end user is using HTTPS and the end user's IP address. The standard is to use the X-Forwarded-Proto and X-Forwarded-For headers, respectively. However, the application shouldn't blindly trust these headers, as doing so would allow anyone to spoof them and cause harm. Users should be able to define CIDR ranges for trusted proxies.
TLS Mode: manual
Allow the user to bring their own certificates. This gives your application more flexibility and covers many cases where the app runs entirely on a private network or behind an external proxy (Cloudflare Strict SSL).
In Hyvor Blogs, we ask the user to mount certs to a standard path in the container:
volumes:
- ./cert.pem:/certs/cert.pem:ro
- ./key.pem:/certs/key.pem:ro
Your application would listen on port 443 and use the user-provided certificates to handle TLS.
TLS Mode: disabled
This would entirely disable TLS for the whole application. It is intended for convenience and is optional to implement.
In-app URL Generation
When generating URLs, such as redirect URLs for OAuth, we first need to know which protocol the end user is using. This is simple: for auto and manual modes, it's always https:// since our app is handling TLS. For external mode, it depends on the X-Forwarded-Proto header. For disabled mode, it's simply http://.
We also want to know the application's domain. If you support a configuration for the domain, use it. Otherwise, we can simply use the Host header of the current request.
How it works in Hyvor Blogs
Hyvor Blogs runs on FrankenPHP, which is a PHP runtime based on Caddy. Therefore, we use Caddy itself to implement this logic.
First, there is a bash script that calculates Caddy placeholders:
# Configure the app domain's Caddy address/TLS directive from TLS_MODE
# auto: Caddy manages a Let's Encrypt certificate automatically
# manual: the certificate/key mounted at /certs/cert.pem and /certs/key.pem are used to serve TLS
# external: TLS is terminated by a reverse proxy in front; the container only serves HTTP
# disabled: no TLS at all, no https redirect
TLS_MODE="${TLS_MODE:-auto}"
case "$TLS_MODE" in
auto)
APP_ADDRESS="$DOMAIN_APP"
APP_TLS_DIRECTIVE=""
;;
manual)
APP_ADDRESS="$DOMAIN_APP"
APP_TLS_DIRECTIVE="tls /certs/cert.pem /certs/key.pem"
;;
external)
APP_ADDRESS="$DOMAIN_APP:80"
APP_TLS_DIRECTIVE=""
;;
disabled)
APP_ADDRESS="$DOMAIN_APP:80"
APP_TLS_DIRECTIVE=""
;;
*)
echo "Invalid TLS_MODE: $TLS_MODE. Must be one of: auto, external, manual, disabled." >&2
exit 1
;;
esac
Then, the Caddyfile uses these variables to generate a dynamic configuration that supports all TLS modes. This uses the awesome Caddy feature of environment variable substitution.
{$APP_ADDRESS} {
{$APP_TLS_DIRECTIVE}
# routing...
}
Links & References:
Comments