Docker
The CWA template repository ships a complete Docker Compose stack. Clone the template and you have a fully wired local environment — PHP, Nuxt, Mercure and PostgreSQL — ready in minutes.
Services
Three services, and no separate proxy or hub container: FrankenPHP is the front door, and the Mercure hub runs inside it as a Caddy module.
| Service | Image | Role |
|---|---|---|
php | FrankenPHP | Symfony API, Caddy front door, embedded Mercure hub; proxies non-API traffic to app |
app | Node 26 alpine | Nuxt SSR application (port 3000, published on 3001 in dev) |
database | PostgreSQL 16 alpine | Primary database |
app, not nuxt — so it's docker compose exec app ….Environment Variables
Create .env.local alongside .env — never commit .env.local:
# Database
DATABASE_URL=postgresql://app:secret@database:5432/app?serverVersion=16&charset=utf8
# JWT Authentication
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=your_jwt_passphrase
JWT_COOKIE_SAMESITE=lax
# Mercure (embedded in the php container — override with CADDY_MERCURE_*)
MERCURE_URL=http://php.local/.well-known/mercure
MERCURE_PUBLIC_URL=https://localhost/.well-known/mercure
CADDY_MERCURE_JWT_SECRET=your_mercure_secret
# Nuxt (server-side API URL = internal Docker network; browser URL = public)
NUXT_PUBLIC_CWA_API_URL=https://php.local/_api
NUXT_PUBLIC_CWA_API_URL_BROWSER=https://localhost/_api
# Email
MAILER_DSN=smtp://smtp-relay:25
These are the template's own defaults. The two CWA API URL variables are intentionally different in Docker Compose: NUXT_PUBLIC_CWA_API_URL is how the Nuxt container reaches PHP over the Docker network (php.local is a link alias on the app service), while NUXT_PUBLIC_CWA_API_URL_BROWSER is the public-facing URL clients use from their browsers. Override them with API_URL and API_URL_BROWSER, and the Mercure URLs with CADDY_MERCURE_URL / CADDY_MERCURE_PUBLIC_URL.
Starting the Stack
# Start all services in the background — the php entrypoint waits for the
# database, runs migrations and ANALYZEs before Caddy accepts traffic
docker compose up -d
# Create your first admin user
docker compose exec php bin/console silverback:api-components:user:create
# Load fixtures (optional)
docker compose exec php bin/console doctrine:fixtures:load
Development Workflow
compose.override.yaml mounts source directories into the containers:
- PHP: your
api/directory is mounted; PHP changes are reflected immediately (no build step) - Nuxt (
appservice):app/is mounted; Vite HMR updates the browser on save - Xdebug: installed in the dev image but off by default (
XDEBUG_MODE=off); run withXDEBUG_MODE=debugto enable it, then connect via your IDE on port 9003
JWT Key Generation
Generate JWT keys once per environment before first start:
docker compose exec php bash -c "
mkdir -p config/jwt
openssl genpkey -out config/jwt/private.pem -aes256 -algorithm rsa -pkeyopt rsa_keygen_bits:4096
openssl pkey -in config/jwt/private.pem -out config/jwt/public.pem -pubout
"
The passphrase you use must match JWT_PASSPHRASE in .env.local.
Building for Production
Multi-stage Dockerfiles produce lean production images:
# Build PHP image
docker build --target frankenphp_prod -t ghcr.io/your-org/app-php:latest ./api
# Build Nuxt image
docker build --target prod -t ghcr.io/your-org/app-nuxt:latest ./app
PHP Dockerfile stages:
builder— custom FrankenPHP build (xcaddy, adding the Mercure, Vulcain and Souin cache modules)frankenphp_base— shared runtime, extensions and Caddy configfrankenphp_dev/frankenphp_prod— the two targets you build
Nuxt Dockerfile stages:
base— corepack/pnpm setupdev—pnpm devwith HMRbuilder—pnpm build(outputs.output/)prod— copies.output/into/srv/appand runsnode server/index.mjsas an unprivileged user
Migrations are not baked into the image. They are run at container start: the template's php entrypoint waits for the database, runs doctrine:migrations:migrate --no-interaction --all-or-nothing, then ANALYZEs.
php replica, concurrent starts race on that migration. For multi-replica deployments, disable the entrypoint migration and run it once as a pre-deploy job instead.Production Docker Compose
For production, use compose.prod.yaml in place of the dev override:
docker compose -f compose.yaml -f compose.prod.yaml --env-file .env.production up -d
It selects the frankenphp_prod and prod build targets. It refuses to start unless SERVER_NAME (the site's domain), APP_SECRET, POSTGRES_PASSWORD, CADDY_MERCURE_JWT_SECRET, JWT_SECRET_KEY, JWT_PUBLIC_KEY and JWT_PASSPHRASE are set.
The Nuxt container renders over http://php.local/_api and warms the page cache over https://php.local, where php.local is a network alias of the php service. Plain HTTP works there because php.local:80 is in Caddy's SERVER_NAME; any other internal host, such as http://php, gets a 308 to HTTPS. See Choosing the warm origin for why the two URLs differ. Projects created before the template's 2.0.0-alpha.2 release set none of these on the app service, so add them.
Tag images with the git SHA for immutable, rollback-capable deploys:
docker build --target frankenphp_prod -t ghcr.io/your-org/app-php:$GIT_SHA ./api
docker push ghcr.io/your-org/app-php:$GIT_SHA
Common Gotchas
NUXT_PUBLIC_CWA_API_URL vs NUXT_PUBLIC_CWA_API_URL_BROWSER: Must be different when your API is on an internal Docker hostname. The server-side URL uses the Docker service name; the browser URL must be the public domain.
JWT keys: Generate once and mount as a secret — never bake private keys into the image. The template's api/.dockerignore keeps config/jwt/*.pem, decrypted Symfony secrets and public/uploads/ out of the image. They're gitignored, so a CI build never has them, but an image built on a developer's machine used to copy them in. Projects generated before components-web-app 176fa9d should add the same lines.
Caddy's admin API is only reachable inside the php container: The admin API on port 2019 replaces Caddy's config and purges or flushes the Souin cache. The template listens on localhost:2019 only and doesn't publish the port, so run admin requests inside the container, for example docker compose exec php curl -s -X PURGE http://localhost:2019/souin-api/souin/flush. CACHE_URL is http://localhost:2019/souin-api/souin. Projects generated before components-web-app 0170978 publish port 2019 on the host and listen on every interface. Remove the port from compose.yaml and change the Caddyfile's admin address to localhost:2019.
Database migrations on restart: The template runs migrations from the php entrypoint every time the container starts. There is no migration Job. This is safe with one php replica, which is the Helm chart's default. If you run more than one php replica, the containers can start at the same time and run the same migration together. In that case, move the migration out of the entrypoint and into a pre-deploy Job.
Mercure cookie SameSite: Set JWT_COOKIE_SAMESITE=none and Secure: true if your API and front-end are on different subdomains. On the same domain, strict is safe.
Every page returns 502/503 after you run pnpm install on the host: In development, compose.override.yaml bind-mounts app/ and app/node_modules into the app container, and that container runs pnpm install and then pnpm dev. A pnpm install on your machine, such as for a dependency bump, rewrites the shared node_modules while the container's dev server is still running from it. The dev server then crash-loops with errors like Cannot find module 'typescript', a missing @nuxt/cli/dist/dev/index.mjs or Cannot resolve module "@nuxt/kit", and Caddy returns 502 or 503 for every page. Restart the container so it reinstalls from inside:
docker compose restart app
When you check its logs afterwards, only look at lines after the restart. The crash from before it is still in the log and looks like a new failure.
Caddy reports unexpected EOF after editing the Caddyfile: If Caddy fails with unexpected EOF while api/frankenphp/Caddyfile looks valid on disk, it's a stale-config issue, not a syntax error. On macOS/Windows (Docker Desktop), a single-file bind mount caches the file's size, so an edit that makes the file longer — what most editors' atomic save does — is served to the container truncated. Edits that shrink or keep the length work, which makes it look random. Confirm by comparing byte counts:
wc -c < api/frankenphp/Caddyfile
docker compose exec php sh -c 'wc -c < /app/frankenphp/Caddyfile'
If they differ, recreate the container (restart isn't enough): docker compose up -d --force-recreate php. The CWA template avoids this by reading dev config through the ./api directory mount rather than mounting single files — you'll only hit it if you reintroduce a single-file mount in compose.override.yaml. Production is unaffected: the image bakes the Caddyfile in.
Every dev page takes ~30 seconds: This is almost always missing PostgreSQL statistics, not slow I/O. CWA's components use Doctrine JOINED inheritance, so loading a page emits a wide multi-table join. The component subclass tables hold only a handful of rows each — permanently below the threshold that triggers PostgreSQL's autoanalyze — so those tables are never analysed, the planner has no statistics, and it wildly over-estimates the join and throws parallel workers at a query that returns almost nothing. Fix it by analysing the database once:
docker compose exec database psql -U app -d app -c 'ANALYZE;'
The current template does this automatically in the php entrypoint on every compose up, so fresh projects don't hit it — but a project scaffolded before that change, or a new small fixture table, can still trigger it.
EXPLAIN (ANALYZE, BUFFERS) and check SELECT relname, reltuples FROM pg_class for tables reporting -1 (never analysed).composer update fails with a raw.githubusercontent.com 404: If Composer dies downloading a Symfony Flex recipes index with an HTTP 404, the cause is usually a stale GitHub token in the container's /config/composer/auth.json, not a network or GitHub outage. GitHub returns 404, not 401, for a bad token on raw.githubusercontent.com — and a plain curl of the same URL returns 200 because it sends no token, which misleadingly "proves" the network is fine. The /config volume persists across compose down/up, so the bad token survives restarts. Clear it:
docker compose exec php rm -f /config/composer/auth.json
Then re-run composer update (or set a valid GITHUB_TOKEN).
A Caddyfile cache matcher never matches when a cookie is absent: If you edit the Souin @use_cache expression in the Caddyfile, note that an absent cookie does not equal "" in Caddy matchers — unlike a missing header, which does. A clause like {http.request.cookie.api_component} == "" only matches when the cookie is present but empty, so requests with no cookie at all (SSR, curl, health checks) silently miss the cache. Match on the Cookie header instead, so an absent or empty cookie both count as "not authenticated":
!{http.request.header.Cookie}.matches("api_component=[^;]+")
@use_cache caches page HTML as well as the API: The template's matcher has two branches. The first caches /_api responses. The second caches the HTML pages that Nuxt renders. The module tags these pages by default; set cwa.pageCache.enabled: false to turn this off. The module adds the IRI of every resource used on the page, and the constant key cwa-html. When the API saves a resource, its existing purge removes every page that used that resource. Saving site config removes every page, through cwa-html. See Page Caching for how long a page is kept, and Purging Every Rendered Page for the API side.
The HTML branch skips some paths on purpose. Keep these exclusions if you edit it:
/.well-known/— Mercure uses server-sent events. A cached SSE response never finishes, so connections build up and the site looks like it has gone down./_nuxt/— these files are already immutable, and caching large bundles would push useful entries out of the in-memory cache./uploads/,/bundles/,/_cwa/, and the login,/forgot-password,/reset-password, email-verification and user-area pages — their content depends on the user, or they are not rendered by Nuxt.
Projects generated before components-web-app 2669a47 exclude /password-reset, which isn't a page, so /forgot-password and every /reset-password/<username>/<token> link were cached, one entry per token. Replace it with the two real paths.
Tracking parameters are stripped before the cache: The Caddyfile's uri @tracking_query query { … } block removes utm_*, gclid, fbclid and other tracking parameters, so they don't split the cache. Nuxt and php never receive them. It runs only when the query contains one of them, and never for /_nuxt/*. See Tracking parameters for the list and how to change it.
The dev server loads, but nothing on the page responds (Nuxt 4.5): If pages render but never hydrate, so buttons do nothing and the login form reloads the page, check the browser console for a SyntaxError about __unhead_devtoolsPlugin being declared twice. @unhead/bundler 3.4.1 registers its DevTools runtime import once for each of Nuxt's client and server Vite servers. Turn off unhead's own DevTools panel in nuxt.config.ts:
export default defineNuxtConfig({
unhead: {
vite: {
devtools: false,
},
},
})
This only affects development. Nuxt DevTools and useSeoMeta still work, and the production build is unchanged. The template sets this from components-web-app 6e69b24 (#95). Remove it once an unhead release fixes the duplicate.
Comments inside the @use_cache expression break Caddy, and frankenphp adapt does not warn you: The text between the backticks is a CEL expression, not Caddyfile syntax. CEL has no comments. A # line inside the backticks is a parse error, but frankenphp adapt does not check the text inside the backticks, so it reports no problem. The error only appears at runtime, and the php container keeps restarting with token recognition error at: '#'. Put comments above the block. After changing it, run adapt, then recreate the container and check that it becomes healthy:
docker compose up -d --force-recreate php
docker compose ps php # should reach "healthy", not "Restarting"
A page that uses a draft component is never cached (older module builds): When an anonymous visitor loads the page, the request for the unpublished component returns a 404 with Cache-Control: no-cache, private. Module builds before 26f06f8d combined that private response into the page's headers, which made the whole page private, no-store. Current builds ignore a 4xx API response when working out the page's headers (cwa-nuxt-module#324), so update the module if one page is never cached. A 5xx from the API still counts, so a private error response stops the page being cached. See When a page is not cached.