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