Configuring SSL
SSL_MODE turns on HTTPS in the fpm-nginx, fpm-apache, and frankenphp images. The container can generate a self-signed certificate or load one you provide. FrankenPHP can also get a trusted certificate from Let's Encrypt.
Supported variations
| Variation | HTTPS in the container | Let's Encrypt in the container |
|---|---|---|
cli | ❌ | ❌ |
fpm | ❌ | ❌ |
fpm-nginx | ✅ | ❌ Use a reverse proxy |
fpm-apache | ✅ | ❌ Use a reverse proxy |
frankenphp | ✅ | ✅ |
SSL modes
SSL_MODE | Port 8080 | Port 8443 |
|---|---|---|
off (default) | Serves HTTP | Not used |
mixed | Serves HTTP | Serves HTTPS |
full | Redirects to HTTPS | Serves HTTPS |
In full mode, port 8080 keeps listening so it can redirect. The redirect goes to https:// on your domain with no port, which means port 443. Publish 443:8443 on the host.
The containers run unprivileged and can't bind to ports below 1024. Map your host ports to 8080 and 8443 (80:8080, 443:8443).
Choose where HTTPS ends
| Approach | Certificate | Renewal | Zero-downtime deployments | Containers |
|---|---|---|---|---|
| Reverse proxy | ✅ Let's Encrypt | ✅ Automatic | ✅ Yes | 2 |
| FrankenPHP automatic HTTPS | ✅ Let's Encrypt | ✅ Automatic | ❌ No | 1 |
| Bring a certificate | ✅ Any vendor | ❌ Manual | ❌ No | 1 |
| Self-signed | ❌ Browser warning | ✅ Automatic | ❌ No | 1 |
Reverse proxy (recommended)

A reverse proxy such as Traefik or Caddy listens on ports 80 and 443 and forwards requests to your container. It gets certificates from Let's Encrypt. Because the proxy stays up while you replace containers behind it, this is also how you get zero-downtime deployments.
Point the proxy at port 8080 and leave SSL_MODE at off. If you want traffic between the proxy and the container encrypted too, set SSL_MODE to mixed or full and point the proxy at port 8443. The container's self-signed certificate works for that hop if you set the proxy to accept it. Set TRUSTED_PROXY so your logs show the visitor's IP instead of the proxy's.
Setting up the proxy is outside the scope of these docs. Spin sets up Traefik with these images if you want it done for you.
Certificate files
The fpm-nginx, fpm-apache, and frankenphp images read the certificate and key from these paths:
| Variable | Default |
|---|---|
SSL_CERTIFICATE_FILE | /etc/ssl/private/self-signed-web.crt |
SSL_PRIVATE_KEY_FILE | /etc/ssl/private/self-signed-web.key |
When SSL_MODE is mixed or full, the startup script 5-generate-ssl.sh checks both paths:
- Both files exist: the container uses them.
- Neither exists: the container generates a self-signed certificate for
localhost, valid for 365 days, at those paths. - Only one exists: the container stops with an error naming both paths.
Use your own certificate
Mount your certificate and key, then point the two variables at them. The certificate file should hold your certificate followed by its intermediate certificates (the full chain), in PEM format.
services:
php:
image: serversideup/php:8.5-fpm-nginx
ports:
- 80:8080
- 443:8443
environment:
SSL_MODE: "full"
SSL_CERTIFICATE_FILE: "/etc/ssl/custom/example.com.crt"
SSL_PRIVATE_KEY_FILE: "/etc/ssl/custom/example.com.key"
volumes:
- .:/var/www/html
- ./certs:/etc/ssl/custom:ro
The web server runs as www-data and must be able to read the key. A key that only your host user can read (600 owned by your UID) stops NGINX with Permission denied. Give the key to www-data, which is UID 33 on Debian and UID 82 on Alpine:
sudo chown 33:33 certs/example.com.key
chmod 600 certs/example.com.key
To build the certificate into your image instead, COPY --chown=www-data:www-data it to the same paths.
You renew this certificate yourself. Restart the container after you replace the files so the web server loads them.
Self-signed certificate
Set SSL_MODE without providing certificate files and the container generates one at startup:
services:
php:
image: serversideup/php:8.5-fpm-nginx
ports:
- 80:8080
- 443:8443
environment:
SSL_MODE: "mixed"
volumes:
- .:/var/www/html
The container now serves HTTP on 8080 and HTTPS on 8443, which this file publishes on host ports 80 and 443. Browsers show a warning for self-signed certificates. Use them for local HTTPS testing or to encrypt traffic behind a reverse proxy. A new container generates a new certificate.
FrankenPHP automatic HTTPS
The frankenphp image can get and renew a Let's Encrypt certificate itself through Caddy. Your server must be reachable from the internet on ports 80 and 443 for the domain you set.
| Variable | Default | Set to |
|---|---|---|
CADDY_AUTO_HTTPS | off | on, to turn on Caddy's automatic HTTPS |
CADDY_HTTPS_SERVER_ADDRESS | https:// | Your domain, such as example.com or https://example.com |
SSL_MODE | off | full or mixed |
Caddy stores certificates in /data and its configuration in /config. Mount both as volumes so certificates survive a new container; otherwise every deployment requests new ones and can hit Let's Encrypt's rate limits. Named volumes work as is. A bind mount must be writable by www-data.
services:
php:
image: serversideup/php:8.5-frankenphp
ports:
- 80:8080
- 443:8443
environment:
CADDY_AUTO_HTTPS: "on"
CADDY_HTTPS_SERVER_ADDRESS: "example.com"
SSL_MODE: "full"
volumes:
- .:/var/www/html
- caddy_config:/config
- caddy_data:/data
volumes:
caddy_config:
caddy_data:
FrankenPHP binds the host ports itself. You can't start a new container alongside the old one. Put a reverse proxy in front if you need zero-downtime deployments.
Short-lived and IP address certificates
Let's Encrypt's shortlived profile issues certificates that last about 6 days. It is required for IP address certificates. Select it with CADDY_ACME_PROFILE:
| Variable | Default | Values |
|---|---|---|
CADDY_ACME_PROFILE | off | shortlived, tlsserver, or classic |
off makes Caddy request certificates from Let's Encrypt only, without its usual ZeroSSL fallback. Short-lived certificates renew about every 2 days. The container needs reliable outbound access to Let's Encrypt.Clients that connect by IP address send no server name. Tell Caddy which certificate to serve with default_sni:
services:
php:
image: serversideup/php:8.5-frankenphp
environment:
CADDY_AUTO_HTTPS: "on"
CADDY_ACME_PROFILE: "shortlived"
CADDY_GLOBAL_OPTIONS: "default_sni example.com"