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, PostgreSQL, and a reverse proxy — ready in minutes.
Services
| Service | Image | Role |
|---|---|---|
php | FrankenPHP | Symfony API (HTTP + Mercure publisher) |
nuxt | Node 22 | Nuxt SSR application |
database | PostgreSQL 16 | Primary database |
mercure | Caddy + Mercure module | Real-time hub |
traefik | Traefik | Reverse proxy routing |
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=strict
# Mercure
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://mercure.localhost/.well-known/mercure
MERCURE_JWT_SECRET=your_mercure_secret
# Nuxt (server-side API URL = internal Docker network; browser URL = public)
NUXT_PUBLIC_CWA_API_URL=http://php
NUXT_PUBLIC_CWA_API_URL_BROWSER=https://api.localhost
# Email
MAILER_DSN=smtp://localhost:1025
The two CWA API URL variables are intentionally different in Docker Compose: NUXT_PUBLIC_CWA_API_URL is the internal Docker hostname (Nuxt → PHP on the Docker network), while NUXT_PUBLIC_CWA_API_URL_BROWSER is the public-facing URL clients use from their browsers.
Starting the Stack
# Start all services in the background
docker compose up -d
# Run database migrations
docker compose exec php bin/console doctrine:migrations:migrate
# 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
docker-compose.override.yml mounts source directories into the containers:
- PHP: your
api/directory is mounted; PHP changes are reflected immediately (no build step) - Nuxt:
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 runner -t ghcr.io/your-org/app-php:latest ./api
# Build Nuxt image
docker build --target runner -t ghcr.io/your-org/app-nuxt:latest ./app
PHP Dockerfile stages:
deps— install Composer dependenciesbuilder— copy app, remove dev dependenciesrunner— FrankenPHP with production config
Nuxt Dockerfile stages:
deps—pnpm installbuilder—pnpm build(outputs.output/)runner— copies.output/, runsnode .output/server/index.mjs
Migrations are not run at image build time. Run them as a separate step in your deploy process before starting the new containers.
Production Docker Compose
For production, remove the docker-compose.override.yml and use the base docker-compose.yml with your production environment file:
docker compose --env-file .env.production up -d
Tag images with the git SHA for immutable, rollback-capable deploys:
docker build -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: Don't run migrations in the container CMD. Race conditions occur when multiple pods start simultaneously. Run them as 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.