diff --git a/.castor/context.php b/.castor/context.php index 8b0c7ab..e8d1230 100644 --- a/.castor/context.php +++ b/.castor/context.php @@ -22,6 +22,8 @@ function create_default_context(): Context 'docker-compose.dev.yml', ], 'docker_compose_run_environment' => [], + // Service in which `docker_compose_run()` executes commands (composer, bin/console...) + 'docker_compose_run_service' => 'builder', 'macos' => false, 'power_shell' => false, // check if posix_geteuid is available, if not, use getmyuid (windows) @@ -90,3 +92,27 @@ function create_ci_context(): Context ]) ; } + +/** + * Production images (see the "Production stages" of the Dockerfile): the same tasks as in + * dev, on a dedicated compose stack. E.g. `castor start -c prod`, `castor docker:push -c prod`. + */ +#[AsContext(name: 'prod')] +function create_prod_context(): Context +{ + $c = create_default_context(); + + return $c->withData( + [ + // Dedicated compose project: never collides with the dev stack (containers, volumes) + 'project_name' => $c['project_name'] . '-prod', + 'registry' => getenv('REGISTRY') ?: $c['registry'], + 'docker_compose_files' => [ + 'docker-compose.prod.yml', + ], + // There is no builder service in this stack: commands run in the application image + 'docker_compose_run_service' => 'php', + ], + recursive: false, + ); +} diff --git a/.castor/docker.php b/.castor/docker.php index dad0d1e..76834d6 100644 --- a/.castor/docker.php +++ b/.castor/docker.php @@ -33,6 +33,13 @@ function about(): void io()->comment('Run castor help [command] to display Castor help.'); io()->section('Available URLs for this project:'); + + if (!has_router()) { + io()->listing([\sprintf('http://127.0.0.1:%s', getenv('HTTP_PORT') ?: '8080')]); + + return; + } + $urls = [variable('root_domain'), ...variable('extra_domains')]; try { @@ -215,6 +222,11 @@ function destroy( } docker_compose(['down', '--remove-orphans', '--volumes', '--rmi=local'], profiles: ['*']); + + if (!has_router()) { + return; + } + $files = finder() ->in(variable('root_dir') . '/infrastructure/docker/services/router/certs/') ->name('*.pem') @@ -228,6 +240,12 @@ function generate_certificates( #[AsOption(description: 'Force the certificates re-generation without confirmation', shortcut: 'f')] bool $force = false, ): void { + if (!has_router()) { + io()->comment('No router in this stack, no SSL certificates to generate.'); + + return; + } + $sslDir = variable('root_dir') . '/infrastructure/docker/services/router/certs'; if (file_exists("{$sslDir}/cert.pem") && !$force) { @@ -408,12 +426,13 @@ function docker_compose(array $subCommand, ?Context $c = null, array $profiles = function docker_compose_run( array $params, ?Context $c = null, - string $service = 'builder', + ?string $service = null, bool $noDeps = true, ?string $workDir = null, bool $portMapping = false, ): Process { $c ??= context(); + $service ??= $c['docker_compose_run_service']; $command = [ 'run', @@ -478,9 +497,27 @@ function docker_exit_code( return $process->getExitCode() ?? 0; } -#[AsTask(description: 'Push images cache to the registry', namespace: 'docker', name: 'push', aliases: ['push'])] -function push(bool $dryRun = false): void +/** + * Whether the current stack includes the traefik router (dev), which needs SSL certificates + * and gives the project its URLs. + */ +function has_router(): bool { + return isset(get_services()['router']); +} + +/** + * Pushes the build cache of every service declaring a `cache_from`. With `--tag`, the images + * themselves are pushed too, e.g. `castor docker:push -c prod --tag=abc1234 --tag=latest`. + * + * @param list $tag + */ +#[AsTask(description: 'Push images cache (and images, with --tag) to the registry', namespace: 'docker', name: 'push')] +function push( + bool $dryRun = false, + #[AsOption(description: 'Also push the images, with this tag (repeatable)', mode: InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED)] + array $tag = [], +): void { $registry = variable('registry'); if (!$registry) { @@ -522,12 +559,17 @@ function push(bool $dryRun = false): void } } + // Image name without its tag, e.g. "ghcr.io/jolicode/qotd/php" + $image = isset($config['image']) ? preg_replace('{:[^/]+$}', '', $config['image']) : "{$registry}/{$service}"; + $targets[] = [ 'reference' => $reference, 'type' => $type, 'context' => $config['build']['context'], + 'additional_contexts' => $config['build']['additional_contexts'] ?? [], 'dockerfile' => $config['build']['dockerfile'] ?? 'Dockerfile', 'target' => $config['build']['target'] ?? null, + 'tags' => array_map(static fn (string $t) => "{$image}:{$t}", $tag), ]; } @@ -542,21 +584,28 @@ function push(bool $dryRun = false): void ); foreach ($targets as $target) { + $hclList = static fn (array $values) => implode(', ', array_map(static fn ($v) => \sprintf('"%s"', $v), $values)); + $contexts = implode('', array_map(static fn ($name, $path) => \sprintf("\n %s = \"%s\"", $name, $path), array_keys($target['additional_contexts']), $target['additional_contexts'])); + $content .= \sprintf( <<<'EOHCL' target "%s" { context = "%s" + contexts = {%s + } dockerfile = "%s" cache-from = ["%s"] cache-to = ["type=%s,ref=%s,mode=max"] target = "%s" + tags = [%s] + output = [%s] args = { PHP_VERSION = "%s" } } EOHCL, - $target['target'], $target['context'], $target['dockerfile'], $target['reference'], $target['type'], $target['reference'], $target['target'], variable('php_version') + $target['target'], $target['context'], $contexts, $target['dockerfile'], $target['reference'], $target['type'], $target['reference'], $target['target'], $hclList($target['tags']), $hclList($target['tags'] ? ['type=registry'] : []), variable('php_version') ); } @@ -575,7 +624,7 @@ function push(bool $dryRun = false): void } /** - * @return array, build: array{context: string, dockerfile?: string, cache_from?: list, target?: string}}> + * @return array, build: array{context: string, dockerfile?: string, cache_from?: list, target?: string, additional_contexts?: array}}> */ function get_services(?string $profile = null): array { diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..87a2f26 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Used by production builds (additional build context "app" pointing here). +# Only source files needed at runtime should be sent to the build context. +.git +.github +.castor +.home +doc +infrastructure +node_modules +tests +tools +var +vendor +.editorconfig +.gitattributes +.php-cs-fixer.php +phpstan-baseline.neon +phpstan.neon +rector.php +.env.dev +.env.test +*.md +castor.php +.gitignore +LICENSE +phpunit.xml.dist + +# Mirrors .gitignore: local/generated files that must never end up baked +# into the image (some carry secrets, e.g. the prod secrets decryption key) +.castor.stub.php +.php-cs-fixer.cache +.env.local +.env.local.php +.env.*.local +.phpunit.result.cache +phpunit.xml +config/secrets/prod/prod.decrypt.private.php +public/assets +public/bundles +public/uploads +assets/vendor diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml new file mode 100644 index 0000000..e6feb28 --- /dev/null +++ b/.github/workflows/build-push.yml @@ -0,0 +1,42 @@ +name: Build and push production images + +"on": + push: + branches: ["main"] + tags: ["*"] + +permissions: + contents: read + packages: write + +env: + # Fix for symfony/color detection. We know GitHub Actions can handle it + ANSICON: 1 + CASTOR_CONTEXT: prod + REGISTRY: ghcr.io/${{ github.repository }} + +jobs: + build-push: + name: Build and push production images + runs-on: ubuntu-latest + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to registry + shell: bash + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + - uses: actions/checkout@v7 + + - name: setup-castor + uses: castor-php/setup-castor@v1.1.0 + + # Images are tagged with the short commit sha (what a deployment should + # reference), "latest" on main, and the git tag when there is one + - name: "Build and push the production images" + run: >- + castor docker:push + --tag="${GITHUB_SHA::7}" + ${{ github.ref_name == 'main' && '--tag=latest' || '' }} + ${{ github.ref_type == 'tag' && format('--tag={0}', github.ref_name) || '' }} diff --git a/README.md b/README.md index 47c45b2..a242bcb 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ To make the application available locally at the address services: frontend: ports: - - "8000:80" + - "8000:8080" ``` > [!NOTE] @@ -79,9 +79,56 @@ services: volumes: - .:/app ports: - - 8888:80 + - 8888:8080 ``` +## Production + +The application ships as two self-contained Docker images, built from the +"production stages" of `infrastructure/docker/services/php/Dockerfile`: + +* `php`: php-fpm listening on the unix socket `/var/run/php/php-fpm.sock`, + with the code, the vendors and the compiled assets baked in, `APP_ENV=prod`. + It is also the CLI image: the cron job (`bin/console qotd:run`) and the + database migrations run with it; +* `nginx`: the official nginx image, the compiled `public/` directory and the + site configuration, forwarding PHP requests to that socket. + +Both use the php-fpm and nginx configuration of the dev container +(`services/php/php/` and `services/php/nginx/`); what production does +differently is in `services/php/php/mods-available/app-prod.ini`. Everything +else (secrets, database, +Slack and Google credentials) is provided through environment variables at +runtime, and `public/uploads` (medias downloaded from Slack) must be a volume +shared by all the containers. + +On every push to `main` (and on every git tag), the "Build and push production +images" workflow pushes both images to `ghcr.io//php` and +`ghcr.io//nginx`, tagged with the short commit sha, `latest` on +`main`, and the tag name when there is one. + +### Testing the production images locally + +The `prod` castor context runs the usual tasks on a dedicated compose stack +(`docker-compose.prod.yml`: postgres + the two images, no bind mount, no +router), independent from the development one: + + castor build -c prod # builds the php and nginx images + castor start -c prod # starts the stack and runs the migrations + # -> http://127.0.0.1:8080 (HTTP_PORT=18080 castor start -c prod to change the port) + castor destroy -c prod # removes the containers and the volumes + +It uses dummy secrets: to test a real Google login or a real Slack workspace, +put the corresponding variables in a `.env.prod.local` file at the root of the +repository (ignored by git, loaded by the php container). `APP_DEFAULT_URI` +and the Google callback URL must then match your local URL. + +To push images from your machine instead of waiting for the CI (you need to be +logged in to the registry with `docker login ghcr.io`, and a buildx builder +able to export a registry cache, e.g. `docker buildx create --use`): + + REGISTRY=ghcr.io// castor docker:push -c prod --tag=my-test + ## Usage In slack you have one commands diff --git a/castor.php b/castor.php index 0aeeecf..8d3295e 100644 --- a/castor.php +++ b/castor.php @@ -12,6 +12,7 @@ use function docker\about; use function docker\build; use function docker\docker_compose_run; +use function docker\get_services; use function docker\up; guard_min_version('1.5.0'); @@ -51,6 +52,12 @@ function install(): void { io()->title('Installing the application'); + if (!isset(get_services()['builder'])) { + io()->comment('No builder service in this stack: the application is embedded in the images, nothing to install.'); + + return; + } + $basePath = variable('root_dir'); if (is_file("{$basePath}/composer.json")) { diff --git a/infrastructure/docker/docker-compose.prod.yml b/infrastructure/docker/docker-compose.prod.yml new file mode 100644 index 0000000..2047b91 --- /dev/null +++ b/infrastructure/docker/docker-compose.prod.yml @@ -0,0 +1,78 @@ +# Production images: self-contained (code baked in, no bind mounts), non-root, +# see the "Production stages" of services/php/Dockerfile. +# +# This file is only meant for LOCAL TESTING of those images, through the "prod" +# castor context: `castor build -c prod` and `castor docker:push -c prod --tag=...` +# build and push the "php" and "nginx" images (the CI does it on every push to +# main), `castor start -c prod` runs them in a minimal stack (dummy secrets, a +# throwaway database, no cron). The real production is not run with this file: +# the hosting orchestrator runs the images with its own database, volumes and +# cron job. + +x-php-build: &php-build + context: services/php + additional_contexts: + app: ../.. + args: + PHP_VERSION: "${PHP_VERSION:-8.5}" + +x-app-env: &app-env + APP_SECRET: "${APP_SECRET:-not-a-secret}" + DATABASE_URL: "postgresql://qotd:qotd@postgres:5432/qotd?serverVersion=16&charset=utf8" + +volumes: + postgres-data: {} + # php-fpm unix socket, shared between the php and nginx containers + php-socket: {} + # Medias downloaded from Slack, written by `bin/console qotd:run`, served by nginx + uploads: {} + +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: qotd + POSTGRES_PASSWORD: qotd + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U qotd"] + interval: 5s + timeout: 5s + retries: 5 + + php: + build: + <<: *php-build + target: php + cache_from: + - "type=registry,ref=${REGISTRY:-ghcr.io/jolicode/qotd}/php:cache" + image: "${REGISTRY:-ghcr.io/jolicode/qotd}/php:${TAG:-latest}" + environment: *app-env + env_file: + # Optional, gitignored: real credentials (Slack, Google...) for a local test. + # Variables of the "environment" block above take precedence. + - path: ../../.env.prod.local + required: false + volumes: + - php-socket:/var/run/php + - uploads:/var/www/public/uploads + depends_on: + postgres: + condition: service_healthy + + nginx: + build: + <<: *php-build + target: nginx + cache_from: + - "type=registry,ref=${REGISTRY:-ghcr.io/jolicode/qotd}/nginx:cache" + image: "${REGISTRY:-ghcr.io/jolicode/qotd}/nginx:${TAG:-latest}" + ports: + - "${HTTP_PORT:-8080}:8080" + volumes: + - php-socket:/var/run/php + - uploads:/var/www/public/uploads + depends_on: + php: + condition: service_healthy diff --git a/infrastructure/docker/services/php/Dockerfile b/infrastructure/docker/services/php/Dockerfile index 7d5c6f7..238cda3 100644 --- a/infrastructure/docker/services/php/Dockerfile +++ b/infrastructure/docker/services/php/Dockerfile @@ -27,7 +27,8 @@ RUN apt-get update \ php${PHP_VERSION}-zip \ && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/* -COPY base/php-configuration /etc/php/${PHP_VERSION} +# Every PHP setting of every image: each stage enables its own ones with phpenmod +COPY php/mods-available/ /etc/php/${PHP_VERSION}/mods-available/ ENV PHP_VERSION=${PHP_VERSION} ENV HOME=/home/app @@ -45,15 +46,19 @@ RUN apt-get update \ RUN useradd -s /bin/false nginx -COPY frontend/php-configuration /etc/php/${PHP_VERSION} -COPY frontend/etc/nginx/. /etc/nginx/ +# Same php-fpm pool and nginx site as the production images (see "Production stages"), the +# container runs as the host user so the socket directory must be writable by anyone +COPY php/fpm/php-fpm.conf /etc/php/${PHP_VERSION}/fpm/php-fpm.conf +COPY nginx/nginx.conf /etc/nginx/nginx.conf +COPY nginx/conf.d/ /etc/nginx/conf.d/ +RUN mkdir -p /var/run/php && chmod 1777 /var/run/php RUN rm -rf /etc/service/ -COPY frontend/etc/service/. /etc/service/ -RUN chmod 777 /etc/service/*/supervise/ +COPY runit/ /etc/service/ +RUN mkdir -p /etc/service/nginx/supervise /etc/service/php-fpm/supervise && chmod 777 /etc/service/*/supervise/ -RUN phpenmod app-default && phpenmod app-fpm +RUN phpenmod app-default && phpenmod -s fpm app-fpm -EXPOSE 80 +EXPOSE 8080 CMD ["runsvdir", "-P", "/etc/service"] FROM php-base AS worker @@ -66,7 +71,6 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends git make php${PHP_VERSION}-dev sudo unzip \ && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/* -COPY builder/php-configuration /etc/php/${PHP_VERSION} RUN phpenmod app-default && phpenmod app-builder COPY --from=composer/composer:2.10.0 /usr/bin/composer /usr/bin/composer @@ -102,3 +106,80 @@ COPY cron/entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] CMD ["cron", "-f"] + +# ------------------------- Production stages ------------------------- +# +# Two self-contained images, "php" and "nginx": the application code is baked +# in (no bind mounts), dependencies are installed without dev packages, assets +# are compiled and containers run as a non-root user. They talk to each other +# through the php-fpm unix socket /var/run/php/php-fpm.sock, shared via a +# volume (docker compose) or an emptyDir (Kubernetes). Cron jobs and workers +# are run by the orchestrator with the "php" image (`bin/console ...`). +# +# The php-fpm and nginx configuration is the one of the dev "frontend" stage +# (php/ and nginx/), the production differences live in php/mods-available/app-prod.ini. +# +# The application source code is expected in the additional build context +# named "app", pointing to the repository root (its .dockerignore applies): +# docker buildx build --build-context app=. --target php . + +FROM builder AS app-build + +ENV APP_ENV=prod +ENV APP_DEBUG=0 +ENV COMPOSER_ALLOW_SUPERUSER=1 + +# Dependencies first, for better layer caching +COPY --from=app composer.json composer.lock symfony.lock ./ +RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts --optimize-autoloader + +COPY --from=app . . +RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader \ + && composer dump-env prod \ + && bin/console asset-map:compile \ + && bin/console cache:warmup + +FROM php-base AS php + +ENV APP_ENV=prod +ENV APP_DEBUG=0 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends php${PHP_VERSION}-fpm libfcgi-bin \ + && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/* \ + && rm -r "/etc/php/${PHP_VERSION}/fpm/pool.d/" + +# Same php-fpm pool as the dev "frontend" stage, plus the production-only settings +COPY php/fpm/php-fpm.conf /etc/php/${PHP_VERSION}/fpm/php-fpm.conf +RUN phpenmod app-default && phpenmod -s fpm app-fpm && phpenmod app-prod + +RUN groupadd -g 1000 app \ + && useradd -M -u 1000 -g 1000 -s /bin/false app \ + && mkdir -p /var/run/php \ + && chown app:app /var/run/php + +WORKDIR /var/www + +COPY --from=app-build --chown=app:app /var/www /var/www + +# Mount point of the uploads volume (medias downloaded from Slack): owned by the +# application user so that the volume inherits it when it is first created +RUN mkdir -p public/uploads && chown app:app public/uploads + +USER 1000:1000 + +HEALTHCHECK --interval=10s --timeout=5s --retries=3 \ + CMD SCRIPT_NAME=/ping SCRIPT_FILENAME=/ping cgi-fcgi -bind -connect /var/run/php/php-fpm.sock || exit 1 + +CMD ["sh", "-c", "exec /usr/sbin/php-fpm${PHP_VERSION}"] + +FROM nginx:1.28-alpine AS nginx + +COPY nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf + +COPY --from=app-build /var/www/public /var/www/public + +EXPOSE 8080 + +HEALTHCHECK --interval=10s --timeout=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/ping || exit 1 diff --git a/infrastructure/docker/services/php/frontend/etc/nginx/environments b/infrastructure/docker/services/php/frontend/etc/nginx/environments deleted file mode 100644 index e69de29..0000000 diff --git a/infrastructure/docker/services/php/frontend/etc/nginx/nginx.conf b/infrastructure/docker/services/php/frontend/etc/nginx/nginx.conf deleted file mode 100644 index a303e3e..0000000 --- a/infrastructure/docker/services/php/frontend/etc/nginx/nginx.conf +++ /dev/null @@ -1,81 +0,0 @@ -user nginx; -pid /tmp/nginx.pid; -daemon off; -error_log /proc/self/fd/2; - -include /etc/nginx/modules-enabled/*.conf; - -http { - access_log /proc/self/fd/1; - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - types_hash_max_size 2048; - include /etc/nginx/mime.types; - default_type application/octet-stream; - client_max_body_size 20m; - server_tokens off; - - gzip on; - gzip_disable "msie6"; - gzip_vary on; - gzip_proxied any; - gzip_comp_level 6; - gzip_buffers 16 8k; - gzip_http_version 1.1; - gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; - - client_body_temp_path /tmp/nginx-client_body_temp_path; - fastcgi_temp_path /tmp/nginx-fastcgi_temp_path; - proxy_temp_path /tmp/nginx-proxy_temp_path; - scgi_temp_path /tmp/nginx-scgi_temp_path; - uwsgi_temp_path /tmp/nginx-uwsgi_temp_path; - - server { - listen 0.0.0.0:80; - root /var/www/public; - - location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg)$ { - access_log off; - add_header Cache-Control "no-cache"; - } - - # Remove this block if you want to access to PHP FPM monitoring - # dashboard (on URL: /php-fpm-status). WARNING: on production, you must - # secure this page (by user IP address, with a password, for example) - location ~ ^/php-fpm-status$ { - deny all; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - fastcgi_index index.php; - include fastcgi_params; - fastcgi_pass 127.0.0.1:9000; - } - - location / { - # try to serve file directly, fallback to index.php - try_files $uri /index.php$is_args$args; - } - - location ~ ^/index\.php(/|$) { - fastcgi_pass 127.0.0.1:9000; - fastcgi_split_path_info ^(.+\.php)(/.*)$; - - include fastcgi_params; - include environments/*; - - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - fastcgi_param HTTPS on; - fastcgi_param SERVER_NAME $http_host; - # # Uncomment if you want to use /php-fpm-status endpoint **with** - # # real request URI. It may have some side effects, that's why it's - # # commented by default - # fastcgi_param SCRIPT_NAME $request_uri; - } - - error_log /proc/self/fd/2; - access_log /proc/self/fd/1; - } -} - -events {} diff --git a/infrastructure/docker/services/php/frontend/etc/service/nginx/supervise/.gitkeep b/infrastructure/docker/services/php/frontend/etc/service/nginx/supervise/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/infrastructure/docker/services/php/frontend/etc/service/php-fpm/supervise/.gitkeep b/infrastructure/docker/services/php/frontend/etc/service/php-fpm/supervise/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/infrastructure/docker/services/php/frontend/php-configuration/fpm/php-fpm.conf b/infrastructure/docker/services/php/frontend/php-configuration/fpm/php-fpm.conf deleted file mode 100644 index 3b74d34..0000000 --- a/infrastructure/docker/services/php/frontend/php-configuration/fpm/php-fpm.conf +++ /dev/null @@ -1,16 +0,0 @@ -[global] -error_log = /proc/self/fd/2 -daemonize = no - -[www] -listen = 127.0.0.1:9000 -pm = dynamic -pm.max_children = 25 -pm.start_servers = 2 -pm.min_spare_servers = 2 -pm.max_spare_servers = 3 -pm.max_requests = 500 -pm.status_path = /php-fpm-status -clear_env = no -request_terminate_timeout = 120s -catch_workers_output = yes diff --git a/infrastructure/docker/services/php/nginx/conf.d/default.conf b/infrastructure/docker/services/php/nginx/conf.d/default.conf new file mode 100644 index 0000000..265fe82 --- /dev/null +++ b/infrastructure/docker/services/php/nginx/conf.d/default.conf @@ -0,0 +1,53 @@ +# Site configuration, shared by the dev "frontend" container and the production "nginx" +# image. PHP requests are forwarded to php-fpm through the shared unix socket. On +# Kubernetes, the "symfony-app" chart can replace this file with its own ConfigMap. +server { + listen 8080; + server_name _; + root /var/www/public; + + client_max_body_size 20m; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; + + # Fingerprinted by the asset mapper, so they can be cached forever. Served from the + # disk once compiled (production), by the asset mapper dev server otherwise + location /assets/ { + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri /index.php$is_args$args; + } + + # php-fpm ping, used by the images HEALTHCHECK + location = /ping { + access_log off; + include fastcgi_params; + fastcgi_pass unix:/var/run/php/php-fpm.sock; + fastcgi_param SCRIPT_NAME /ping; + fastcgi_param SCRIPT_FILENAME /ping; + } + + location / { + # try to serve file directly, fallback to index.php + try_files $uri /index.php$is_args$args; + } + + location ~ ^/index\.php(/|$) { + fastcgi_pass unix:/var/run/php/php-fpm.sock; + fastcgi_split_path_info ^(.+\.php)(/.*)$; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $document_root; + # TLS is always terminated upstream (dev router, ingress, reverse proxy) + fastcgi_param HTTPS on; + fastcgi_param SERVER_NAME $http_host; + internal; + } + + location ~ \.php$ { + return 404; + } +} diff --git a/infrastructure/docker/services/php/nginx/nginx.conf b/infrastructure/docker/services/php/nginx/nginx.conf new file mode 100644 index 0000000..7a5567a --- /dev/null +++ b/infrastructure/docker/services/php/nginx/nginx.conf @@ -0,0 +1,30 @@ +# nginx of the dev "frontend" container (Debian package, runs as the host user under runit). +# The site itself is /etc/nginx/conf.d/default.conf, shared with the production image. +user nginx; +pid /tmp/nginx.pid; +daemon off; +error_log /proc/self/fd/2; + +include /etc/nginx/modules-enabled/*.conf; + +http { + access_log /proc/self/fd/1; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + include /etc/nginx/mime.types; + default_type application/octet-stream; + server_tokens off; + + client_body_temp_path /tmp/nginx-client_body_temp_path; + fastcgi_temp_path /tmp/nginx-fastcgi_temp_path; + proxy_temp_path /tmp/nginx-proxy_temp_path; + scgi_temp_path /tmp/nginx-scgi_temp_path; + uwsgi_temp_path /tmp/nginx-uwsgi_temp_path; + + include /etc/nginx/conf.d/*.conf; +} + +events {} diff --git a/infrastructure/docker/services/php/php/fpm/php-fpm.conf b/infrastructure/docker/services/php/php/fpm/php-fpm.conf new file mode 100644 index 0000000..4850a81 --- /dev/null +++ b/infrastructure/docker/services/php/php/fpm/php-fpm.conf @@ -0,0 +1,22 @@ +[global] +error_log = /proc/self/fd/2 +daemonize = no + +[www] +; Unix socket shared with nginx: same container in dev (runit), sidecar container in production +; (volume or emptyDir mounted on /var/run/php) +listen = /var/run/php/php-fpm.sock +; nginx does not necessarily run with the same user as php-fpm +listen.mode = 0666 +pm = dynamic +pm.max_children = 25 +pm.start_servers = 2 +pm.min_spare_servers = 2 +pm.max_spare_servers = 3 +pm.max_requests = 500 +; Used by the images HEALTHCHECK +ping.path = /ping +clear_env = no +request_terminate_timeout = 120s +catch_workers_output = yes +decorate_workers_output = no diff --git a/infrastructure/docker/services/php/builder/php-configuration/mods-available/app-builder.ini b/infrastructure/docker/services/php/php/mods-available/app-builder.ini similarity index 100% rename from infrastructure/docker/services/php/builder/php-configuration/mods-available/app-builder.ini rename to infrastructure/docker/services/php/php/mods-available/app-builder.ini diff --git a/infrastructure/docker/services/php/base/php-configuration/mods-available/app-default.ini b/infrastructure/docker/services/php/php/mods-available/app-default.ini similarity index 100% rename from infrastructure/docker/services/php/base/php-configuration/mods-available/app-default.ini rename to infrastructure/docker/services/php/php/mods-available/app-default.ini diff --git a/infrastructure/docker/services/php/frontend/php-configuration/mods-available/app-fpm.ini b/infrastructure/docker/services/php/php/mods-available/app-fpm.ini similarity index 100% rename from infrastructure/docker/services/php/frontend/php-configuration/mods-available/app-fpm.ini rename to infrastructure/docker/services/php/php/mods-available/app-fpm.ini diff --git a/infrastructure/docker/services/php/php/mods-available/app-prod.ini b/infrastructure/docker/services/php/php/mods-available/app-prod.ini new file mode 100644 index 0000000..dbe50f4 --- /dev/null +++ b/infrastructure/docker/services/php/php/mods-available/app-prod.ini @@ -0,0 +1,10 @@ +; priority=50 +; Everything PHP does differently in production compared to the dev "frontend" container +[PHP] +display_errors = Off +; Native file sessions: let PHP garbage collect them (Debian normally relies on a system cron for that) +session.gc_probability = 1 + +[opcache] +; The code is baked into the image: no need to check files for changes on each request +opcache.validate_timestamps = 0 diff --git a/infrastructure/docker/services/php/frontend/etc/service/nginx/run b/infrastructure/docker/services/php/runit/nginx/run similarity index 100% rename from infrastructure/docker/services/php/frontend/etc/service/nginx/run rename to infrastructure/docker/services/php/runit/nginx/run diff --git a/infrastructure/docker/services/php/frontend/etc/service/php-fpm/run b/infrastructure/docker/services/php/runit/php-fpm/run similarity index 100% rename from infrastructure/docker/services/php/frontend/etc/service/php-fpm/run rename to infrastructure/docker/services/php/runit/php-fpm/run