diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dc5a875 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +**/*.log +**/*.md +**/*.php~ +**/*.dist.php +**/*.dist +**/*.cache +**/._* +**/.dockerignore +**/.DS_Store +**/.git/ +**/.gitattributes +**/.gitignore +**/.gitmodules +**/compose.*.yaml +**/compose.*.yml +**/compose.yaml +**/compose.yml +**/docker-compose.*.yaml +**/docker-compose.*.yml +**/docker-compose.yaml +**/docker-compose.yml +**/Dockerfile +**/Thumbs.db +.github/ +docs/ +public/bundles/ +tests/ +var/ +vendor/ +.editorconfig +.env.*.local +.env.local +.env.local.php +.env.test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..57c6f28 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,178 @@ +#syntax=docker/dockerfile:1 + +# Versions +FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream + +# The different stages of this Dockerfile are meant to be built into separate images +# https://docs.docker.com/build/building/multi-stage/#stop-at-a-specific-build-stage +# https://docs.docker.com/reference/compose-file/build/#target + + +# Base FrankenPHP image +FROM frankenphp_upstream AS frankenphp_base + +SHELL ["/bin/bash", "-euxo", "pipefail", "-c"] + +WORKDIR /app + +# persistent deps +# hadolint ignore=DL3008 +RUN <<-EOF + apt-get update + apt-get install -y --no-install-recommends \ + file \ + git + install-php-extensions \ + @composer \ + apcu \ + intl \ + opcache \ + zip + rm -rf /var/lib/apt/lists/* +EOF + +# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser +ENV COMPOSER_ALLOW_SUPERUSER=1 + +ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d" + +###> recipes ### +###< recipes ### + +COPY --link frankenphp/conf.d/10-app.ini $PHP_INI_DIR/app.conf.d/ +COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint +COPY --link frankenphp/Caddyfile /etc/frankenphp/Caddyfile + +ENTRYPOINT ["docker-entrypoint"] + +HEALTHCHECK --start-period=60s CMD php -r 'exit(false === @file_get_contents("http://localhost:2019/metrics", context: stream_context_create(["http" => ["timeout" => 5]])) ? 1 : 0);' +CMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile" ] + +# Dev FrankenPHP image +FROM frankenphp_base AS frankenphp_dev + +ENV APP_ENV=dev +ENV XDEBUG_MODE=off +ENV FRANKENPHP_WORKER_CONFIG=watch + +# dev dependencies +# hadolint ignore=DL3008 +RUN <<-EOF + mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" + apt-get update + apt-get install -y --no-install-recommends \ + aggregate \ + curl \ + dnsmasq \ + dnsutils \ + iproute2 \ + ipset \ + iptables \ + jq \ + sudo + install-php-extensions xdebug + rm -rf /var/lib/apt/lists/* + useradd -m -s /bin/bash nonroot + echo "nonroot ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/nonroot + git config --system --add safe.directory /app +EOF + +COPY --link frankenphp/conf.d/20-app.dev.ini $PHP_INI_DIR/app.conf.d/ + +CMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile", "--watch" ] + +# Builder for the prod FrankenPHP image +FROM frankenphp_base AS frankenphp_prod_builder + +ENV APP_ENV=prod + +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" + +COPY --link frankenphp/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/ + +# prevent the reinstallation of vendors at every changes in the source code +COPY --link composer.* symfony.* ./ +RUN composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress + +# copy sources +COPY --link --exclude=frankenphp/ . ./ + +RUN <<-EOF + mkdir -p var/cache var/log var/share + composer dump-autoload --classmap-authoritative --no-dev + composer dump-env prod + composer run-script --no-dev post-install-cmd + if [ -f importmap.php ]; then + php bin/console asset-map:compile + fi + chmod +x bin/console + chmod -R g=u var + sync +EOF + +# Collect shared libraries needed by FrankenPHP and PHP extensions +# hadolint ignore=DL3008,SC3054,DL4006 +RUN <<-'EOF' + apt-get update + apt-get install -y --no-install-recommends libtree + mkdir -p /tmp/libs + BINARIES=(frankenphp php file) + for target in $(printf '%s\n' "${BINARIES[@]}" | xargs -I{} which {}) \ + $(find "$(php -r 'echo ini_get("extension_dir");')" -maxdepth 2 -name "*.so"); do + libtree -pv "$target" 2>/dev/null | grep -oP '(?:── )\K/\S+(?= \[)' | while IFS= read -r lib; do + [ -f "$lib" ] && cp -n "$lib" /tmp/libs/ + done + done + rm -rf /var/lib/apt/lists/* +EOF + +# Prod FrankenPHP image +FROM debian:13-slim AS frankenphp_prod + +SHELL ["/bin/bash", "-euxo", "pipefail", "-c"] + +ENV APP_ENV=prod +ENV PHP_INI_SCAN_DIR=":/usr/local/etc/php/app.conf.d" + +COPY --from=frankenphp_prod_builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp +COPY --from=frankenphp_prod_builder /usr/local/bin/php /usr/local/bin/php +COPY --from=frankenphp_prod_builder /usr/local/bin/docker-php-entrypoint /usr/local/bin/docker-php-entrypoint +COPY --from=frankenphp_prod_builder /usr/local/lib/php/extensions /usr/local/lib/php/extensions +COPY --from=frankenphp_prod_builder /tmp/libs /usr/lib + +COPY --from=frankenphp_prod_builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d +COPY --from=frankenphp_prod_builder /usr/local/etc/php/php.ini /usr/local/etc/php/php.ini +COPY --from=frankenphp_prod_builder /usr/local/etc/php/app.conf.d /usr/local/etc/php/app.conf.d + +COPY --from=frankenphp_prod_builder /etc/frankenphp/Caddyfile /etc/frankenphp/Caddyfile + +# CA certificates for TLS, file/libmagic for Symfony MIME type detection +COPY --from=frankenphp_prod_builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=frankenphp_prod_builder /etc/ssl/openssl.cnf /etc/ssl/openssl.cnf +COPY --from=frankenphp_prod_builder /usr/bin/file /usr/bin/file +COPY --from=frankenphp_prod_builder /usr/lib/file/magic.mgc /usr/lib/file/magic.mgc + +ENV OPENSSL_CONF=/etc/ssl/openssl.cnf XDG_CONFIG_HOME=/config XDG_DATA_HOME=/data + +RUN <<-EOF + mkdir -p /data/caddy /config/caddy + chown -R www-data:www-data /data /config + # Remove setuid/setgid bits + find / -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true +EOF + +COPY --link --exclude=var --from=frankenphp_prod_builder /app /app +# Group 0 + g=u for arbitrary-UID runtimes (e.g. OpenShift). +COPY --chown=www-data:0 --from=frankenphp_prod_builder /app/var /app/var +RUN chmod g=u /app/var + +COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint + +USER www-data + +WORKDIR /app + +ENTRYPOINT ["docker-entrypoint"] + +HEALTHCHECK --start-period=60s CMD php -r 'exit(false === @file_get_contents("http://localhost:2019/metrics", context: stream_context_create(["http" => ["timeout" => 5]])) ? 1 : 0);' +CMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile" ] diff --git a/compose.override.yaml b/compose.override.yaml new file mode 100644 index 0000000..5e4ca2c --- /dev/null +++ b/compose.override.yaml @@ -0,0 +1,30 @@ +--- +# Development environment override +services: + php: + image: ${IMAGES_PREFIX:-}app-php-dev + build: + context: . + target: frankenphp_dev + volumes: + - ./:/app + - ./frankenphp/Caddyfile:/etc/frankenphp/Caddyfile:ro + - ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro + # Keep var/ off the bind-mount for faster I/O on Mac/Windows; comment to inspect from the host. + - /app/var + # If you develop on Mac or Windows you can remove the vendor/ directory + # from the bind-mount for better performance by enabling the next line: + #- /app/vendor + environment: + FRANKENPHP_WORKER_CONFIG: watch + FRANKENPHP_SITE_CONFIG: hot_reload + MERCURE_EXTRA_DIRECTIVES: demo + # See https://xdebug.org/docs/all_settings#mode + XDEBUG_MODE: "${XDEBUG_MODE:-develop}" + APP_ENV: "${APP_ENV:-dev}" + extra_hosts: + # Ensure that host.docker.internal is correctly defined on Linux + - host.docker.internal:host-gateway + tty: true +###> symfony/mercure-bundle ### +###< symfony/mercure-bundle ### diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..a3e2163 --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,12 @@ +--- +# Production environment override +services: + php: + image: ${IMAGES_PREFIX:-}app-php-prod + build: + context: . + target: frankenphp_prod + environment: + APP_SECRET: ${APP_SECRET} + MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET} + MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..f1f05c8 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,47 @@ +--- +services: + php: + restart: unless-stopped + environment: + SERVER_NAME: ${SERVER_NAME:-localhost}, php:80 + DEFAULT_URI: https://${SERVER_NAME:-localhost}:${HTTPS_PORT:-443} + MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!} + MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!} + # Run "composer require symfony/orm-pack" to install and configure Doctrine ORM + DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15}&charset=${POSTGRES_CHARSET:-utf8} + # Run "composer require symfony/mercure-bundle" to install and configure the Mercure integration + MERCURE_URL: ${CADDY_MERCURE_URL:-http://php/.well-known/mercure} + MERCURE_PUBLIC_URL: ${CADDY_MERCURE_PUBLIC_URL:-https://${SERVER_NAME:-localhost}:${HTTPS_PORT:-443}/.well-known/mercure} + MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!} + ###> dunglas/symfony-docker ### + # Next lines are only used during initial installation + # This block will remove itself after the installation + SYMFONY_VERSION: ${SYMFONY_VERSION:-} + STABILITY: ${STABILITY:-stable} + ###< dunglas/symfony-docker ### + volumes: + - caddy_data:/data + - caddy_config:/config + ports: + - name: http + target: 80 + published: ${HTTP_PORT:-80} + protocol: tcp + - name: https + target: 443 + published: ${HTTPS_PORT:-443} + protocol: tcp + - name: http3 + target: 443 + published: ${HTTP3_PORT:-443} + protocol: udp + +# Mercure is installed as a Caddy module, prevent the Flex recipe from installing another service +###> symfony/mercure-bundle ### +###< symfony/mercure-bundle ### + +volumes: + caddy_data: + caddy_config: +###> symfony/mercure-bundle ### +###< symfony/mercure-bundle ### diff --git a/composer.json b/composer.json index 4325152..7e6ad73 100644 --- a/composer.json +++ b/composer.json @@ -79,7 +79,8 @@ "extra": { "symfony": { "allow-contrib": false, - "require": "8.1.*" + "require": "8.1.*", + "docker": true } } } diff --git a/frankenphp/Caddyfile b/frankenphp/Caddyfile new file mode 100644 index 0000000..fef196e --- /dev/null +++ b/frankenphp/Caddyfile @@ -0,0 +1,66 @@ +{ + skip_install_trust + + {$CADDY_GLOBAL_OPTIONS} + + frankenphp { + {$FRANKENPHP_CONFIG} + } +} + +{$CADDY_EXTRA_CONFIG} + +{$SERVER_NAME:localhost} { + log { + {$CADDY_SERVER_LOG_OPTIONS} + # Redact the authorization query parameter that can be set by Mercure + format filter { + request>uri query { + replace authorization REDACTED + } + } + } + + root /app/public + encode zstd br gzip + + mercure { + # Publisher JWT key + publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG} + # Subscriber JWT key + subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG} + # Allow anonymous subscribers (double-check that it's what you want) + anonymous + # Enable the subscription API (double-check that it's what you want) + subscriptions + # Extra directives + {$MERCURE_EXTRA_DIRECTIVES} + } + + vulcain + + {$CADDY_SERVER_EXTRA_DIRECTIVES} + + # Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics + header ?Permissions-Policy "browsing-topics=()" + + @phpRoute { + not path /.well-known/mercure* + not file {path} + } + rewrite @phpRoute index.php + + @frontController path index.php + php @frontController { + {$FRANKENPHP_SITE_CONFIG} + + worker { + file ./public/index.php + {$FRANKENPHP_WORKER_CONFIG} + } + } + + file_server { + hide *.php + } +} diff --git a/frankenphp/conf.d/10-app.ini b/frankenphp/conf.d/10-app.ini new file mode 100644 index 0000000..92a9d22 --- /dev/null +++ b/frankenphp/conf.d/10-app.ini @@ -0,0 +1,13 @@ +expose_php = 0 +date.timezone = UTC +apc.enable_cli = 1 +session.use_strict_mode = 1 +zend.detect_unicode = 0 + +; https://symfony.com/doc/current/performance.html +realpath_cache_size = 4096K +realpath_cache_ttl = 600 +opcache.interned_strings_buffer = 16 +opcache.max_accelerated_files = 32531 +opcache.memory_consumption = 256 +opcache.enable_file_override = 1 diff --git a/frankenphp/conf.d/20-app.dev.ini b/frankenphp/conf.d/20-app.dev.ini new file mode 100644 index 0000000..729fe45 --- /dev/null +++ b/frankenphp/conf.d/20-app.dev.ini @@ -0,0 +1,5 @@ +; See https://docs.docker.com/desktop/features/networking/networking-how-tos/#connect-a-container-to-a-service-on-the-host +; See https://github.com/docker/for-linux/issues/264 +; The `client_host` below may optionally be replaced with `discover_client_host=yes` +; Add `start_with_request=yes` to start debug session on each request +xdebug.client_host = host.docker.internal diff --git a/frankenphp/conf.d/20-app.prod.ini b/frankenphp/conf.d/20-app.prod.ini new file mode 100644 index 0000000..99cc791 --- /dev/null +++ b/frankenphp/conf.d/20-app.prod.ini @@ -0,0 +1,5 @@ +; https://symfony.com/doc/current/performance.html#use-the-opcache-class-preloading +opcache.preload_user = www-data +opcache.preload = /app/config/preload.php +; https://symfony.com/doc/current/performance.html#don-t-check-php-files-timestamps +opcache.validate_timestamps = 0 diff --git a/frankenphp/docker-entrypoint.sh b/frankenphp/docker-entrypoint.sh new file mode 100644 index 0000000..a73124d --- /dev/null +++ b/frankenphp/docker-entrypoint.sh @@ -0,0 +1,69 @@ +#!/bin/sh +set -e + +if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then + ###> dunglas/symfony-docker ### + # Install the project the first time PHP is started + # This block will remove itself after the installation + if [ "$(cat composer.json)" = '{}' ]; then + rm -Rf tmp/ + composer create-project "symfony/skeleton $SYMFONY_VERSION" tmp --stability="$STABILITY" --prefer-dist --no-progress --no-interaction --no-install + + cd tmp + cp -Rp . .. + cd - + rm -Rf tmp/ + + composer require "php:>=$PHP_VERSION" + composer config --json extra.symfony.docker 'true' + + # Remove the project install block from this script and the compose.yaml + sed -i '/^\t###> dunglas\/symfony-docker ###/,/^\t###< dunglas\/symfony-docker ###/d' frankenphp/docker-entrypoint.sh + sed -i '/###> dunglas\/symfony-docker ###/,/###< dunglas\/symfony-docker ###/d' compose.yaml + + if grep -q ^DATABASE_URL= .env; then + echo 'To finish the installation please press Ctrl+C to stop Docker Compose and run: docker compose up --build --wait' + sleep infinity + fi + fi + ###< dunglas/symfony-docker ### + + if [ -z "$(ls -A 'vendor/' 2>/dev/null)" ]; then + composer install --prefer-dist --no-progress --no-interaction + fi + + # Display information about the current project + # Or about an error in project initialization + php bin/console -V + + if grep -q ^DATABASE_URL= .env; then + echo 'Waiting for database to be ready...' + ATTEMPTS_LEFT_TO_REACH_DATABASE=60 + until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do + if [ $? -eq 255 ]; then + # If the Doctrine command exits with 255, an unrecoverable error occurred + ATTEMPTS_LEFT_TO_REACH_DATABASE=0 + break + fi + sleep 1 + ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1)) + echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left." + done + + if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then + echo 'The database is not up or not reachable:' + echo "$DATABASE_ERROR" + exit 1 + else + echo 'The database is now ready and reachable' + fi + + if [ "$(find ./migrations -iname '*.php' -print -quit)" ]; then + php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing + fi + fi + + echo 'PHP app ready!' +fi + +exec docker-php-entrypoint "$@"