Deployment And Production

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

VariationHTTPS in the containerLet's Encrypt in the container
cli❌❌
fpm❌❌
fpm-nginx✅❌ Use a reverse proxy
fpm-apache✅❌ Use a reverse proxy
frankenphp✅✅

SSL modes

SSL_MODEPort 8080Port 8443
off (default)Serves HTTPNot used
mixedServes HTTPServes HTTPS
fullRedirects to HTTPSServes 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

ApproachCertificateRenewalZero-downtime deploymentsContainers
Reverse proxy✅ Let's Encrypt✅ Automatic✅ Yes2
FrankenPHP automatic HTTPS✅ Let's Encrypt✅ Automatic❌ No1
Bring a certificate✅ Any vendor❌ Manual❌ No1
Self-signed❌ Browser warning✅ Automatic❌ No1

A reverse proxy receives traffic on ports 80 and 443 and forwards it to PHP containers on port 8080 while a new container starts

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:

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

compose.yml
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:

Terminal
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:

compose.yml
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.

VariableDefaultSet to
CADDY_AUTO_HTTPSoffon, to turn on Caddy's automatic HTTPS
CADDY_HTTPS_SERVER_ADDRESShttps://Your domain, such as example.com or https://example.com
SSL_MODEofffull 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.

compose.yml
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:

VariableDefaultValues
CADDY_ACME_PROFILEoffshortlived, tlsserver, or classic
Any value other than 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:

compose.yml
services:
  php:
    image: serversideup/php:8.5-frankenphp
    environment:
      CADDY_AUTO_HTTPS: "on"
      CADDY_ACME_PROFILE: "shortlived"
      CADDY_GLOBAL_OPTIONS: "default_sni example.com"