Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/.env
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,5 @@ MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=https://localhost
###< symfony/routing ###

CACHE_INVALIDATION_URL=http://localhost:2019/souin-api/souin
Comment thread
vincentchalamon marked this conversation as resolved.
26 changes: 26 additions & 0 deletions api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,35 @@ FROM docker.io/dunglas/frankenphp:1-php8.5 AS frankenphp_upstream
# https://docs.docker.com/compose/compose-file/#target


# Build FrankenPHP with Mercure, Vulcain and Souin (HTTP cache) modules
FROM docker.io/dunglas/frankenphp:1-builder AS builder

# Install xcaddy to build a custom FrankenPHP binary with additional Caddy modules
# CGO_ENABLED=0: xcaddy is a pure Go tool and must not be compiled with CGO
# hadolint ignore=DL3062
RUN CGO_ENABLED=0 go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

# hadolint ignore=DL3062
RUN CGO_ENABLED=1 \
CGO_CFLAGS="$(php-config --includes)" \
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
XCADDY_GO_BUILD_FLAGS="-tags=nobadger,nomysql,nopgx" \
"$(go env GOPATH)/bin/xcaddy" build \
--output /usr/local/bin/frankenphp \
--with github.com/dunglas/caddy-cbrotli \
--with github.com/dunglas/frankenphp/caddy \
--with github.com/dunglas/mercure/caddy \
--with github.com/dunglas/vulcain/caddy \
--with github.com/darkweak/souin/plugins/caddy \
--with github.com/darkweak/storages/otter/caddy \
--with github.com/darkweak/storages/redis/caddy


# Base FrankenPHP image
FROM frankenphp_upstream AS frankenphp_base

COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp

WORKDIR /app

# persistent / runtime deps
Expand Down
16 changes: 16 additions & 0 deletions api/config/packages/api_platform.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,19 @@ services:
$nameConverter: '@?api_platform.name_converter'
$properties: { name: 'ipartial' }
tags: [ 'api_platform.filter' ]

when@prod:
api_platform:
http_cache:
invalidation:
enabled: true
purger: 'app.http_cache.purger.fault_tolerant_souin'
urls:
- '%env(CACHE_INVALIDATION_URL)%'

services:
app.http_cache.purger.fault_tolerant_souin:
class: App\HttpCache\FaultTolerantSouinPurger
arguments:
$inner: '@api_platform.http_cache.purger.souin'
$logger: '@logger'
2 changes: 2 additions & 0 deletions api/frankenphp/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
header ?Permissions-Policy "browsing-topics=()"

route {
{$CADDY_SERVER_CACHE}

# Matches requests for OIDC routes
@oidc expression path('/oidc/*')

Expand Down
40 changes: 40 additions & 0 deletions api/src/HttpCache/FaultTolerantSouinPurger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace App\HttpCache;

use ApiPlatform\HttpCache\PurgerInterface;
use Psr\Log\LoggerInterface;

/**
* Wraps the Souin purger to prevent cache invalidation failures from crashing the application.
*
* When the Caddy admin API is unreachable (e.g. during CLI commands like doctrine:fixtures:load),
* exceptions are caught and logged as warnings instead of propagating.
*/
final readonly class FaultTolerantSouinPurger implements PurgerInterface
{
public function __construct(
private PurgerInterface $inner,
private LoggerInterface $logger,
) {
}

public function purge(array $iris): void
{
try {
$this->inner->purge($iris);
} catch (\Throwable $throwable) {
$this->logger->warning('Failed to purge HTTP cache: {message}', [
'message' => $throwable->getMessage(),
'exception' => $throwable,
]);
}
}

public function getResponseHeaders(array $iris): array
{
return $this->inner->getResponseHeaders($iris);
}
}
4 changes: 4 additions & 0 deletions compose.e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ services:
- gutendex.com

php:
environment:
# Disable HTTP cache during E2E tests: Souin would cache empty API responses
# during service startup (before fixtures are loaded) and serve them during tests
CADDY_SERVER_CACHE: ""
volumes:
- ./e2e/mock-server/cert.pem:/usr/local/share/ca-certificates/mock-server.crt:ro
depends_on:
Expand Down
15 changes: 15 additions & 0 deletions compose.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ services:
APP_SECRET: ${APP_SECRET}
MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET}
MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET}
CADDY_GLOBAL_OPTIONS: |
order cache before rewrite
cache {
ttl 3600s
stale 86400s
default_cache_control "public, s-maxage=3600"
redis {
url redis:6379
}
otter
api {
souin
}
}
CADDY_SERVER_CACHE: "cache"

pwa:
image: ${PWA_DOCKER_IMAGE}
Expand Down
15 changes: 15 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ services:
condition: service_started
keycloak:
condition: service_started
redis:
condition: service_healthy
restart: unless-stopped
environment:
CACHE_INVALIDATION_URL: "http://localhost:2019/souin-api/souin"
PWA_UPSTREAM: pwa:3000
OIDC_UPSTREAM: keycloak:8080
SERVER_NAME: ${SERVER_NAME:-localhost}, php:80
Expand Down Expand Up @@ -91,6 +94,17 @@ services:
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###

redis:
image: docker.io/redis:8-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
- redis_data:/data

keycloak-database:
image: docker.io/postgres:${KEYCLOAK_POSTGRES_VERSION:-16}-alpine
environment:
Expand Down Expand Up @@ -144,4 +158,5 @@ volumes:
###< doctrine/doctrine-bundle ###
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###
redis_data:
keycloak_db_data:
31 changes: 31 additions & 0 deletions docs/adr/0005-http-cache-with-souin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# HTTP Cache with Souin

* Status: accepted
* Deciders: @vincentchalamon

## Context and Problem Statement

The demo application exposes public HTTP cache headers (`Cache-Control: public`) on API responses, but no cache layer actually stores and serves cached responses. Every request hits the PHP backend, even for unchanged resources. The demo needs an HTTP cache that integrates natively with FrankenPHP (Caddy) and API Platform's automatic cache invalidation, while also serving as an example for distributed deployments.

## Considered Options

**Varnish** is the traditional HTTP cache for API Platform projects. It is battle-tested and well-documented. However, it requires a separate service (reverse proxy) in front of the application, adding operational complexity. It does not integrate natively with Caddy/FrankenPHP — the two processes must be orchestrated separately, and Varnish uses its own configuration language (VCL).

**Souin** is an HTTP cache module written in Go that compiles directly into Caddy as a plugin. Since FrankenPHP is built on Caddy, Souin runs in the same process — no additional service, no network hop for cache lookups. It is RFC 7234 compliant, supports surrogate key invalidation (which API Platform uses natively via `SouinPurger`), and offers pluggable storage backends. The trade-off is that it requires a custom FrankenPHP build via `xcaddy`.

**CloudFlare / CDN** would offload caching entirely to an external provider. This works well in production but cannot be demonstrated locally, and ties the demo to a specific vendor.

## Decision Outcome

We chose **Souin** because it integrates natively with the existing FrankenPHP/Caddy stack — no extra service to manage, no network hop, and built-in support in API Platform via `api_platform.http_cache.purger.souin`. This makes the demo a realistic showcase of HTTP caching with automatic invalidation.

For storage, we use a **two-tier architecture: Otter (L1) + Redis (L2)**. Otter is an in-memory cache local to each process, providing the fastest possible read path. Redis is a shared, persistent store that serves two purposes: distributing the cache across multiple pods in a Kubernetes deployment, and surviving process restarts so the cache is not lost on every deploy. This combination demonstrates a production-ready pattern for distributed projects, which is a key goal of the demo application.

The cache is compiled into all images (dev and prod) but **only activated in production** via environment variables. This keeps the developer experience unchanged while ensuring the production setup is always tested with the same binary.

## Links

* [Souin — HTTP cache module for Caddy](https://github.com/darkweak/souin)
* [Souin Caddy plugin documentation](https://docs.souin.io/docs/middlewares/caddy/)
* [API Platform HTTP Cache documentation](https://api-platform.com/docs/core/performance/)
* [FrankenPHP custom builds](https://frankenphp.dev/docs/compile/)
4 changes: 3 additions & 1 deletion helm/api-platform/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ data:
mercure-url: "http://{{ include "api-platform.fullname" . }}/.well-known/mercure"
mercure-public-url: {{ .Values.mercure.publicUrl | default "http://127.0.0.1/.well-known/mercure" | quote }}
mercure-extra-directives: {{ .Values.mercure.extraDirectives | quote }}
caddy-global-options: {{ .Values.php.caddyGlobalOptions | quote }}
caddy-global-options: {{ tpl .Values.php.caddyGlobalOptions . | quote }}
caddy-server-cache: {{ .Values.php.caddyServerCache | default "" | quote }}
cache-invalidation-url: "http://localhost:2019/souin-api/souin"
oidc-server-url: "https://{{ (first .Values.ingress.hosts).host }}/oidc/realms/demo"
oidc-server-url-internal: "http://{{ include "api-platform.fullname" . }}-keycloak/oidc/realms/demo"
better-auth-url: "https://{{ (first .Values.ingress.hosts).host }}/api/auth"
Expand Down
10 changes: 10 additions & 0 deletions helm/api-platform/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ spec:
secretKeyRef:
name: {{ include "api-platform.fullname" . }}
key: mercure-jwt-secret
- name: CADDY_SERVER_CACHE
valueFrom:
configMapKeyRef:
name: {{ include "api-platform.fullname" . }}
key: caddy-server-cache
- name: CACHE_INVALIDATION_URL
valueFrom:
configMapKeyRef:
name: {{ include "api-platform.fullname" . }}
key: cache-invalidation-url
ports:
- name: http
containerPort: 80
Expand Down
45 changes: 45 additions & 0 deletions helm/api-platform/templates/redis-deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{{- if .Values.redis.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "api-platform.fullname" . }}-redis
labels:
{{- include "api-platform.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
replicas: 1
selector:
matchLabels:
{{- include "api-platform.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: redis
template:
metadata:
labels:
{{- include "api-platform.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: redis
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: redis
image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}"
imagePullPolicy: {{ .Values.redis.image.pullPolicy }}
ports:
- name: redis
containerPort: 6379
protocol: TCP
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
resources:
{{- toYaml .Values.redis.resources | nindent 12 }}
{{- end }}
19 changes: 19 additions & 0 deletions helm/api-platform/templates/redis-service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{{- if .Values.redis.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "api-platform.fullname" . }}-redis
labels:
{{- include "api-platform.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
type: ClusterIP
ports:
- port: 6379
targetPort: redis
protocol: TCP
name: redis
selector:
{{- include "api-platform.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: redis
{{- end }}
33 changes: 27 additions & 6 deletions helm/api-platform/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,26 @@ php:
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
caddyServerCache: "cache"
caddyGlobalOptions: |
debug
servers {
metrics
trusted_proxies static private_ranges
}
# order cache before rewrite
# cache {
# api {
# souin
# }
# }
order cache before rewrite
cache {
ttl 3600s
stale 86400s
default_cache_control "public, s-maxage=3600"
redis {
url {{ include "api-platform.fullname" . }}-redis:6379
}
otter
api {
souin
}
}
resources:
requests:
memory: 100Mi
Expand Down Expand Up @@ -89,6 +97,19 @@ postgresql:
memory: 50Mi
cpu: 1m

redis:
enabled: true
image:
repository: "docker.io/redis"
tag: "8-alpine"
pullPolicy: IfNotPresent
resources:
requests:
memory: 50Mi
cpu: 1m
limits:
memory: 128Mi

keycloak:
enabled: true
image:
Expand Down