M. Niyazi Alpay
M. Niyazi Alpay
M. Niyazi Alpay

I've been interested in computer systems since a very young age, and I've been programming since 2005. I have knowledge in PHP, MySQL, Python, MongoDB, and Linux.

 

about.me/Cryptograph

Docker Compose Server Setup with FrankenPHP & Caddy Supporting 103 Early Hints

Docker Compose Server Setup with FrankenPHP & Caddy Supporting 103 Early Hints

At the 2025 PHP conference, I was introduced to Frankenphp and the Caddy web server. Frankenphp is a PHP service built on the standard PHP we use, with some additional features layered on top. When combined with Caddy web server, it gains support for the 103 Early Hints web response. This 103 status code is not available on the standard Nginx or Apache servers we normally use.

What Is 103 Early Hints?

This response code allows our browser to begin downloading specified CSS, JavaScript, or certain images before the website has fully loaded. In other words, when we make a request to the site, these assets start downloading before the site’s own HTML response arrives, thereby improving the site’s preload speed. We achieve this in PHP with headers_send(103);, but this is not available in standard PHP. If you try to call this function there, you’ll encounter a PHP Fatal error: Uncaught Error: Call to undefined function headers_send(). This function comes built into Frankenphp.
On my Laravel site, I created the following middleware to test it; shortly I will refactor this to be dynamic so it pulls files from whatever theme is currently active.

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EarlyHintsMiddleware
{
    /**
     * Handle an incoming request.
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        $this->frankenphp_send_early_hints([
            '; rel=preload; as=style',
            '; rel=preload; as=style',
            '<'.asset('themes/fontawesome/css/all.min.css').'>; rel=preload; as=style',
            '<'.asset('theme/Cryptograph/css/animate.min.css').'>; rel=preload; as=style',
            '<'.asset('theme/Cryptograph/css/bootstrap.min.css').'>; rel=preload; as=style',
            '<'.asset('theme/Cryptograph/css/slick.min.css').'>; rel=preload; as=style',
            '<'.asset('theme/Cryptograph/css/default.min.css').'>; rel=preload; as=style',
            '<'.asset('theme/Cryptograph/css/style.min.css').'>; rel=preload; as=style',
            '<'.asset('theme/Cryptograph/css/responsive.min.css').'>; rel=preload; as=style',
            '<'.asset('theme/Cryptograph/css/custom.min.css').'>; rel=preload; as=style',
            '<'.asset('theme/Cryptograph/js/vendor/jquery-3.6.0.min.js').'>; rel=preload; as=script',
            '<'.asset('theme/Cryptograph/js/bootstrap.min.js').'>; rel=preload; as=script',
            '<'.asset('theme/Cryptograph/js/main.min.js').'>; rel=preload; as=script',
            '; rel=preload; as=style',
            '; rel=preload; as=script',
            '<'.asset('themes/fontawesome/webfonts/fa-solid-900.woff2').'>; rel=preload; as=font; type=font/woff2; crossorigin',
            '<'.asset('themes/fontawesome/webfonts/fa-brands-400.woff2').'>; rel=preload; as=font; type=font/woff2; crossorigin',
            '<'.asset('themes/fontawesome/webfonts/fa-duotone-900.woff2').'>; rel=preload; as=font; type=font/woff2; crossorigin',
            '<'.asset('themes/fontawesome/webfonts/fa-regular-400.woff2').'>; rel=preload; as=font; type=font/woff2; crossorigin',
        ]);

        return $next($request);
    }

    private function frankenphp_send_early_hints(array $links): void
    {
        foreach ($links as $link) {
            header('Link: '.$link);
            if(function_exists('headers_send')){
                headers_send(103);
            }
        }
    }
}

Now, the main point I want to address is this: I was already planning to move my entire server setup into a Docker Compose–driven architecture, and upon discovering Frankenphp I began building this new structure. That meant moving away from standard Nginx, Apache, and PHP-FPM. Along the way I ran into some issues: some of my sites use outdated software and rely on .htaccess rules that Caddy doesn’t support, causing everything except the homepage to return 404 errors. As a workaround, I found myself adding an Apache service into the Docker Compose.

Here is the docker-compose.yaml file I ended up with:

services:
  alpha_panel_web:
    build:
      context: ./alpha-panel/web
      dockerfile: Dockerfile
    container_name: alpha_panel_web
    hostname: alpha_panel_web
    restart: always
    volumes:
      - ./alpha-panel/web/httpdocs:/var/www/AlphaPanel/httpdocs
      - ./alpha-panel/web/logs:/var/log/caddy
      - ./alpha-panel/web/ssl:/var/www/ssl
      - ./alpha-panel/web/Caddyfile:/etc/frankenphp/Caddyfile
      - ./alpha-panel/web/caddy_data:/data
      - ./vhosts:/var/www/vhosts
    environment:
      PANEL_DOMAIN: ${PANEL_DOMAIN}
      CF_API_TOKEN: ${CF_API_TOKEN}
      ADMIN_EMAIL: ${ADMIN_EMAIL}
    ports:
      - "${PRIVATE_NETWORK_IP}:7443:443"
    networks:
      - vhost_network

  alpha_panel_webhook:
    build:
      context: ./alpha-panel/webhook
      dockerfile: Dockerfile
    container_name: alpha_panel_webhook
    hostname: alpha_panel_webhook
    restart: always
    volumes:
      - ./alpha-panel/webhook:/app
      - ./vhosts:/var/www/vhosts
      - ./alpha-panel/webhook/ssh_key:/root/.ssh
    networks:
      - vhost_network

  frankenphp:
    build:
      context: ./frankenphp
      dockerfile: Dockerfile
    container_name: frankenphp
    hostname: frankenphp
    restart: always
    environment:
      CF_API_TOKEN: ${CF_API_TOKEN}
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      PUBLIC_NETWORK_IP: ${PUBLIC_NETWORK_IP}
      PRIVATE_NETWORK_IP: ${PRIVATE_NETWORK_IP}
    volumes:
        - ./frankenphp/Caddyfile:/etc/frankenphp/Caddyfile
        - ./frankenphp/sites-enabled:/etc/frankenphp/sites-enabled
        - ./frankenphp/caddy_data:/data
        - ./vhosts:/var/www/vhosts
        - ./php-code-server/run/:/run/php/
        - ./frankenphp/logs:/var/log/caddy/
    networks:
      - vhost_network
    depends_on:
      - mysql
      - redis
      - mongodb
      - ftp
      - meilisearch
    ports:
      - "${PUBLIC_NETWORK_IP}:80:80"
      - "${PUBLIC_NETWORK_IP}:443:443"

  php-code-server:
    build:
      context: ./php-code-server
      dockerfile: Dockerfile
    container_name: php-code-server
    hostname: php-code-server
    restart: always
    user: root
    environment:
      - PASSWORD=${CODE_SERVER_PASSWORD}
      - SUDO_PASSWORD=${CODE_SERVER_SUDO_PASSWORD}
      - PROXY_DOMAIN=${CODE_SERVER_DOMAIN}:7443
      - DEFAULT_WORKSPACE=/var/www/vhosts
      - TZ=Etc/UTC
      - ALLOW_ROOT=true
    volumes:
      - ./php-code-server/run/:/run/php/
      - ./vhosts:/var/www/vhosts
      - ./php-code-server/supervisor.d:/etc/supervisor/conf.d/

      - ./php-code-server/8.4/fpm.d/:/etc/php/8.4/fpm/pool.d/
      - ./php-code-server/8.3/fpm.d:/etc/php/8.3/fpm/pool.d/
      - ./php-code-server/8.2/fpm.d:/etc/php/8.2/fpm/pool.d/
      - ./php-code-server/8.1/fpm.d:/etc/php/8.1/fpm/pool.d/
      - ./php-code-server/8.0/fpm.d:/etc/php/8.0/fpm/pool.d/

      - ./php-code-server/php.ini:/etc/php/8.4/fpm/conf.d/99999-custom.ini
      - ./php-code-server/php.ini:/etc/php/8.3/fpm/conf.d/99999-custom.ini
      - ./php-code-server/php.ini:/etc/php/8.2/fpm/conf.d/99999-custom.ini
      - ./php-code-server/php.ini:/etc/php/8.1/fpm/conf.d/99999-custom.ini
      - ./php-code-server/php.ini:/etc/php/8.0/fpm/conf.d/99999-custom.ini

      - ./ftp-config/users.env:/etc/users.env:ro
      - ./code-server/data:/root

      - ./apache/sites-enabled:/etc/apache2/sites-enabled
      - ./apache/conf-enabled/remote_ip.conf:/etc/apache2/conf-enabled/remote_ip.conf
      - ./apache/conf-enabled/cloudflare.conf:/etc/apache2/conf-enabled/cloudflare.conf
      - ./apache/conf-enabled/security.conf:/etc/apache2/conf-enabled/security.conf
      - ./apache/conf-enabled/deflate.conf:/etc/apache2/conf-enabled/deflate.conf
      - ./vhosts:/var/www/vhosts
      - ./php-code-server/run/:/run/php/
    depends_on:
      - mysql
      - mongodb
      - redis
      - meilisearch
      - ftp
    networks:
      - vhost_network

  meilisearch:
    image: getmeili/meilisearch:v1.14
    container_name: meilisearch
    hostname: meilisearch
    volumes:
      - ./meilisearch/data:/meili_data
      - ./meilisearch/tmp:/tmp
    restart: always
    environment:
      - MEILI_ENV=production
      - TMPDIR=/tmp
    ports:
      - "${PRIVATE_NETWORK_IP}:7700:7700"
    networks:
      - vhost_network
    command: ["meilisearch", "--master-key", "${MEILISEARCH_MASTER_KEY}"]

  mysql:
    image: mysql:9.3.0
    container_name: mysql
    hostname: db
    volumes:
      - ./mysql/data:/var/lib/mysql
      - ./mysql/conf.d:/etc/mysql/conf.d
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    ports:
      - "${PRIVATE_NETWORK_IP}:3306:3306"
    networks:
      - vhost_network
#        aliases:
#          - db.niyazi.org

  redis:
    image: redis:latest
    container_name: redis
    hostname: redis.niyazi.org
    restart: always
    volumes:
      - ./redis:/data
    ports:
      - "${PRIVATE_NETWORK_IP}:6379:6379"
    networks:
      - vhost_network

  mongodb:
    image: mongodb/mongodb-community-server:8.0.8-ubuntu2204
    container_name: mongodb
    hostname: mongodb.niyazi.org
    restart: always
    user: root
    volumes:
      - ./mongodb:/data/db
    ports:
      - "${PRIVATE_NETWORK_IP}:27017:27017"
    networks:
      - vhost_network

  backlink_service:
    build:
      context: ./vhosts/backlink.name/service
      dockerfile: Dockerfile
    container_name: backlink_service
    hostname: backlinkdb
    restart: always
    volumes:
      - ./vhosts/backlink.name/service:/app
    networks:
      - vhost_network

  password:
    image: "vaultwarden/server:latest"
    hostname: "${VAULTWARDEN_DOMAIN}"
    container_name: password
    restart: always
    volumes:
      - "./vaultwarden/data:/data/"
    environment:
      - "DATABASE_URL=mysql://${VAULTWARDEN_DB_USER}:${VAULTWARDEN_DB_PASSWORD}@${VAULTWARDEN_DB_HOST}/${VAULTWARDEN_DB_NAME}"
      - "RUST_BACKTRACE=1"
    networks:
      - vhost_network

  phpmyadmin:
    image: phpmyadmin:latest
    container_name: phpmyadmin
    hostname: pma.niyazi.org
    restart: always
    environment:
      PMA_HOST: mysql
      PMA_PORT: 3306
#      PMA_USER: root
#      PMA_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    networks:
      - vhost_network

  ftp:
    image: delfer/alpine-ftp-server    # Alpine + vsftpd, FTPS destekli
    container_name: ftp-server
    hostname: ftp-server
    restart: always
    ports:
      - "${PRIVATE_NETWORK_IP}:21:21"                        # komut kanalı
      - "${PRIVATE_NETWORK_IP}:21000-21010:21000-21010"      # pasif mod port aralığı
    environment:
      # Dış IP veya hostname (PASV yanıtlarında kullanılır)
      ADDRESS: server.niyazi.org
      MIN_PORT: 21000
      MAX_PORT: 21010
    env_file:
      - ./ftp-config/users.env
    entrypoint: ["/init.sh"]
    volumes:
      - ./vhosts:/var/www/vhosts:rw
      - ./ftp-config/users.env:/config/users.env:ro
      - ./ftp-config/init.sh:/init.sh:ro
    networks:
      - vhost_network

networks:
  vhost_network:
    name: vhost_network
    driver: bridge

Most of the additional services I use are included here; before running you can remove any services you won’t need. For example, you can remove alpha_panel_web, alpha_panel_webhook, and backlink_service from the YAML file if you’re not using them. I’m leaving the project’s GitHub link below:

https://github.com/niyazialpay/AlphaWebServer

Muhammed Niyazi ALPAY - Cryptograph

Senior Software Developer & Senior Linux System Administrator

Meraklı

PHP MySQL MongoDB Python Linux Cyber Security

You may also want to read these

There are none comment

Leave a comment

Your email address will not be published. Required fields are marked *