The CWA is in heavy development
The CWA is still in alpha and not ready for production - some code and implementations are likely to change. If you would like to try out the CWA, please enjoy what we have provided and feel free to provide feedback, or get involved on GitHub.
DraftDeployment

Docker

The Docker Compose setup for local development and production — services, environment variables, volumes, and build workflow.

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.

ServiceImageRole
phpFrankenPHPSymfony API, Caddy front door, embedded Mercure hub; proxies non-API traffic to app
appNode 26 alpineNuxt SSR application (port 3000, published on 3001 in dev)
databasePostgreSQL 16 alpinePrimary database
The Nuxt service is called app, not nuxt — so it's docker compose exec app ….

Environment Variables

Create .env.local alongside .envnever 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=strict

# 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://localhost:1025

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 (app service): app/ is mounted; Vite HMR updates the browser on save
  • Xdebug: configured in the override file; 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:

  1. builder — static FrankenPHP build
  2. frankenphp_base — shared runtime, extensions and Caddy config
  3. frankenphp_dev / frankenphp_prod — the two targets you build

Nuxt Dockerfile stages:

  1. base — corepack/pnpm setup
  2. devpnpm dev with HMR
  3. builderpnpm build (outputs .output/)
  4. prod — copies .output/ into /srv/app and runs node server/index.mjs as 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.

With more than one 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 and expects APP_SECRET, POSTGRES_PASSWORD and CADDY_MERCURE_JWT_SECRET to be set.

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.

Database migrations on restart: The template runs migrations from the php entrypoint on every container start, which is convenient for single-replica setups and the Helm chart (there is no migration Job). Once you scale php beyond one replica, simultaneous starts race on the same migration — move it 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.

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.

This is easy to misdiagnose because it's a pure query-planner problem with no I/O. A near-idle database, tiny tables, and a 99% cache-hit ratio are all true and completely consistent with it — none of them rule the database out. If dev is mysteriously slow, go straight to 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=[^;]+")