News

Let's Encrypt HTTPS on an IP Address With FrankenPHP

Published
Let's Encrypt HTTPS on an IP Address With FrankenPHP image

The serversideup/php Docker images, built in the serversideup/docker-php repo, shipped v4.6.0-beta1 with a CADDY_ACME_PROFILE variable that selects Let's Encrypt's shortlived certificate profile. That profile is the only one Let's Encrypt issues IP-address certificates under, so a FrankenPHP container reachable only by IP can serve real HTTPS with no DNS.

Jay Rogers of Server Side Up announced it with the beta:

I've been working on this for a while and finally got it dialed in:

🔐 HTTPS for IP addresses

This is a huge breakthrough for self-hosters. No DNS required. All powered by Let's Encrypt + FrankenPHP.

Self-hosters are the clearest case, and the same problem shows up in plenty of team setups: a staging box spun up for a week, an internal API behind a VPN, a demo server for a client, a queue dashboard on a droplet. All of them can be reachable by IP long before anyone decides on a hostname. Until now, that meant a self-signed certificate and the browser warning that comes with it, or a reverse proxy in front holding the real certificate.

Let's Encrypt made 6-day certificates and IP-address certificates generally available in January. Caddy implements certificate profile selection and IP identifiers, and FrankenPHP embeds Caddy (currently v2.11.4). This beta adds the environment variable and the Caddyfile snippets that Caddy imports based on its value.

How the Short-Lived Profile Works

CADDY_ACME_PROFILE defaults to off and accepts shortlived, tlsserver, or classic. Set it to shortlived, and Caddy asks for certificates good for 160 hours, just under seven days, renewing them every two.

That lifetime puts them inside the CA/Browser Forum's definition of a Short-Lived Subscriber Certificate. Let's Encrypt's profile documentation says those "do not need to contain any revocation information." However, the short-lived profile still points to a certificate revocation list (CRL), the published record of certificates a certificate authority has invalidated early, and that may change. Revocation matters less at that lifetime regardless, since a compromised key stops being useful in days rather than months.

Setting the variable writes a small Caddyfile snippet into the config:

cert_issuer acme {
profile shortlived
}

Let's Encrypt requires the same profile for IP-address certificates, which is why both features landed in one variable. Setting any profile to use Let's Encrypt drops the ZeroSSL fallback the images otherwise keep. Renewing every two days also leaves less margin if the container loses outbound access to Let's Encrypt's ACME API, the interface Caddy uses to request and renew certificates. After a firewall change that blocks egress, you have four and a half days to notice before the certificate expires, whereas a 90-day certificate would have left you weeks.

Configuring It

The feature is only in the beta tags, so pin the image to the beta version rather than the stable 8.4-frankenphp tag. The docs give a Docker Compose config close to this:

services:
php:
image: serversideup/php:8.4-frankenphp-v4.6.0-beta1
ports:
- "80:8080"
- "443:8443"
volumes:
- caddy-config:/config
- caddy-data:/data
environment:
SSL_MODE: "full"
CADDY_AUTO_HTTPS: "on"
CADDY_ACME_PROFILE: "shortlived"
CADDY_HTTPS_SERVER_ADDRESS: "https://203.0.113.10"
CADDY_GLOBAL_OPTIONS: "default_sni example.com"
 
volumes:
caddy-config:
caddy-data:

CADDY_AUTO_HTTPS enables Caddy's automatic HTTPS; it's off by default. SSL_MODE: "full" serves the app over HTTPS and sends HTTP traffic there with a 308. CADDY_HTTPS_SERVER_ADDRESS is where the IP goes. The container listens on 8080 and 8443 because it runs unprivileged; hence the port mapping, and both need to be reachable from the internet for the ACME challenge to succeed.

A browser opening a TLS connection normally announces the hostname it is asking for, a field called Server Name Indication (SNI), and that is how a server with several certificates picks the right one. A client connecting to an IP address sends no SNI, because there is no name to send, so Caddy has nothing to match a certificate against. CADDY_GLOBAL_OPTIONS: "default_sni example.com" gives it a fallback identity for those connections.

If you need this over IPv6, test it before you rely on it. Caddy fixed IPv6 handling for IP certificates later than its IPv4 support, in issue #7399, which was closed in April. Caddy v2.11.4 came after that fix, so the current images should have it, but IPv4 has been working for longer.

Mount /config and /data on volumes as well. Certificates and ACME account state live there, and a container that loses them on every restart will request new certificates on every restart. Let's Encrypt allows five certificates per identical set of identifiers every seven days, refilling one every 34 hours, so a few redeploys can use up the week.

The Rest of the Beta

Elsewhere in v4.6.0-beta1, TRUSTED_PROXY now behaves the same across FrankenPHP, NGINX, and Apache, so request()->ip() returns the visitor rather than the proxy no matter which variation the app runs on. It takes cloudflare (the default), sucuri, local, or off, and every value except off trusts Docker's internal ranges alongside the CDN's, so running behind Cloudflare and a Traefik container at the same time needs no extra configuration. FrankenPHP also ships a caddyfile-global.d directory where you can drop your own global config.

Laravel Nightwatch includes a healthcheck-nightwatch script that runs php artisan nightwatch:status, so you can point a container HEALTHCHECK at the agent. And AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND lets a container with AUTORUN_ENABLED=true exit cleanly when Laravel is not there yet, such as before the first composer install, instead of failing.

v4.6.0-beta1 is tagged as a prerelease, so keep it off anything you cannot afford to break while you try the IP certificates. The full notes are on the v4.6.0-beta1 release page, and the setup is documented under configuring SSL in the Server Side Up docs. If you have not looked at FrankenPHP itself lately, we covered FrankenPHP v1.11.2 earlier this year, and FrankenPHP support in Laravel Octane before that.

Yannick Lyn Fatt photo

Staff Writer at Laravel News and Full stack web developer.

Sponsored

acquaintsoft logo
Acquaint Softtech

Hire Laravel developers with AI expertise at $20/hr. Get started in 48 hours.

Visit Acquaint Softtech

The latest

View all →
Laravel Chores: Resumable Data Operations and Cleanups image

Laravel Chores: Resumable Data Operations and Cleanups

Read article
NativePHP v4: Build Native iOS and Android UI in Blade image

NativePHP v4: Build Native iOS and Android UI in Blade

Read article
Laravel Lock: Distributed Locks for Models and Routes image

Laravel Lock: Distributed Locks for Models and Routes

Read article
Laravel Image Responses: Serve Resized Images From Routes image

Laravel Image Responses: Serve Resized Images From Routes

Read article
Pause All Laravel Queues During a Deploy image

Pause All Laravel Queues During a Deploy

Read article
Laravel Terminal UI for the artisan dev Command image

Laravel Terminal UI for the artisan dev Command

Read article