If you build web apps today, you often want Laravel for the backend and Next.js for a fast React frontend. The challenge is creating a clean local and production setup that feels the same, scales easily, and avoids configuration sprawl. Docker Compose solves this by running each part of your stack in a consistent, portable container.
Executive Overview
This guide shows a practical, production-ready Docker Compose architecture for a Laravel API backend and a Next.js SSR frontend. You will:
- Run PHP-FPM + Nginx for Laravel
- Run Node for Next.js (dev and production)
- Add PostgreSQL and Redis
- Split worker processes for queues and scheduler
- Use one Nginx entrypoint routing /api to Laravel and everything else to Next.js
- Apply best practices: multi-stage builds, healthchecks, env management, caching, and scaling
Architecture at a Glance
All services share one Docker network. Nginx terminates HTTP and reverse-proxies to:
- php: Laravel via PHP-FPM for /api and static assets under /api/public
- frontend: Next.js SSR server for all other routes
| Service |
Role |
Typical Port |
Scaling Notes |
| nginx |
Edge proxy, serves Laravel public, routes to php and frontend |
80 (443 in prod) |
1–3 replicas behind LB; sticky or JWT auth for SSR |
| php |
Laravel app via PHP-FPM |
9000 (internal) |
Scale horizontally; ensure shared storage or object storage |
| queue |
php image running artisan queue:work |
- |
Scale by concurrency and number of workers |
| scheduler |
php image running artisan schedule:work |
- |
Single replica recommended |
| frontend |
Next.js SSR server |
3000 (internal) |
Scale horizontally; ensure stateless sessions |
| db |
PostgreSQL |
5432 |
Use managed DB in production if possible |
| redis |
Cache, sessions, queues |
6379 |
Use dedicated Redis in production for HA |
Directory Layout
Keep the monorepo readable:
.
├── docker
│ ├── nginx.conf
│ ├── Dockerfile.laravel
│ └── Dockerfile.next
├── laravel
│ ├── app
│ ├── bootstrap
│ ├── public
│ ├── composer.json
│ └── ...
├── nextjs
│ ├── app or pages
│ ├── package.json
│ └── next.config.js
├── .env
└── docker-compose.yml
Dockerfiles
Laravel multi-stage Dockerfile
# docker/Dockerfile.laravel
FROM composer:2 AS vendor
WORKDIR /app
COPY laravel/composer.json laravel/composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \
composer install --no-dev --prefer-dist --no-scripts --no-progress --no-interaction -o
FROM php:8.3-fpm-alpine AS php
RUN apk add --no-cache bash fcgi git libpq-dev libzip-dev oniguruma-dev icu-dev \
&& docker-php-ext-install pdo pdo_pgsql intl mbstring zip bcmath \
&& pecl install redis \
&& docker-php-ext-enable redis
WORKDIR /var/www/html
COPY --from=vendor /app/vendor ./vendor
COPY laravel .
RUN chown -R www-data:www-data storage bootstrap/cache
# Set production defaults; override with env
ENV PHP_OPCACHE_VALIDATE_TIMESTAMPS=0
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD php -v || exit 1
CMD ["php-fpm", "-F"]
Next.js multi-stage Dockerfile
# docker/Dockerfile.next
FROM node:20-alpine AS deps
WORKDIR /app
COPY nextjs/package.json nextjs/package-lock.json* nextjs/pnpm-lock.yaml* ./
RUN --mount=type=cache,target=/root/.npm npm ci --legacy-peer-deps
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY nextjs .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Production runner using Next standalone output
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Non-root user for security
RUN addgroup -S next && adduser -S next -G next
USER next
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD node -e "require('http').get('http://localhost:3000', r => process.exit(r.statusCode===200?0:1)).on('error', ()=>process.exit(1))"
CMD node server.js
Compose File with Dev and Prod Profiles
This docker-compose.yml runs the full stack. Use profiles to switch between development and production behavior.
version: '3.9'
services:
nginx:
image: nginx:1.27-alpine
depends_on:
- php
- frontend
ports:
- 8080:80 # use 80/443 in prod behind LB
volumes:
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./laravel/public:/var/www/html/public:ro
networks: [app]
php:
build:
context: .
dockerfile: docker/Dockerfile.laravel
env_file: .env
volumes:
- ./laravel:/var/www/html:rw # dev hot reload
networks: [app]
queue:
build:
context: .
dockerfile: docker/Dockerfile.laravel
command: php artisan queue:work --verbose --tries=3 --timeout=90
env_file: .env
depends_on: [php, redis, db]
networks: [app]
profiles: [prod]
scheduler:
build:
context: .
dockerfile: docker/Dockerfile.laravel
command: php artisan schedule:work
env_file: .env
depends_on: [php, redis, db]
networks: [app]
profiles: [prod]
frontend:
build:
context: .
dockerfile: docker/Dockerfile.next
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
networks: [app]
db:
image: postgres:16-alpine
environment:
- POSTGRES_DB=app
- POSTGRES_USER=app
- POSTGRES_PASSWORD=secret
volumes:
- db-data:/var/lib/postgresql/data
ports:
- 5432:5432
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U app -d app']
interval: 10s
timeout: 5s
retries: 5
networks: [app]
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
ports:
- 6379:6379
volumes:
- redis-data:/data
networks: [app]
volumes:
db-data:
redis-data:
networks:
app:
Nginx reverse proxy config
This routes /api to Laravel (php-fpm) and everything else to Next.js. It also serves Laravel public assets.
# docker/nginx.conf
server {
listen 80;
server_name _;
# Serve Laravel public files
root /var/www/html/public;
index index.php index.html;
# Health check
location = /healthz { return 200 'ok'; add_header Content-Type text/plain; }
# Laravel API and assets under /api
location /api/ {
try_files $uri $uri/ @laravel;
}
location @laravel {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_pass php:9000;
}
# Handle PHP files only under /api/public
location ~ \.php$ {
return 404;
}
# Everything else goes to Next.js SSR
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://frontend:3000;
}
}
Environment Configuration
Create a root-level .env used by php services (do not commit this file):
# Laravel
APP_NAME=App
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8080
# Database
DB_CONNECTION=pgsql
DB_HOST=db
DB_PORT=5432
DB_DATABASE=app
DB_USERNAME=app
DB_PASSWORD=secret
# Cache/Queue
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
REDIS_HOST=redis
REDIS_PORT=6379
# CORS
SANCTUM_STATEFUL_DOMAINS=localhost:8080
SESSION_DOMAIN=localhost
Bootstrapping the Stack
- Generate Laravel app key inside the php container.
docker compose up -d db redis
docker compose run --rm php php artisan key:generate
- Run initial migrations and seed if needed.
docker compose run --rm php php artisan migrate --seed
- Start the full stack.
docker compose up -d
open http://localhost:8080
Local Development Tips
- Bind-mount your Laravel code so edits reflect without rebuilds. For PHP opcache, enable validate_timestamps in dev.
- Next.js dev server is not required here; you can also run npm run dev locally if you prefer HMR speed, bypassing the container for frontend during dev.
- Use tinker and artisan inside the container: docker compose exec php php artisan route:list
- For mail testing, add MailHog: mailhog service on 8025 and point MAIL_HOST=mailhog, MAIL_PORT=1025.
Production Hardening
1) Build images with CI and use immutable tags
Build php and frontend images in CI, push to a registry, and deploy with docker compose pull && docker compose up -d. Avoid bind mounts in prod.
2) Enable TLS and real domains
Terminate TLS at Nginx or an external load balancer. For on-box certs, consider Caddy or Traefik for automatic Let’s Encrypt. If staying with Nginx, provision certs and update listen 443 ssl and ssl_certificate paths.
3) Use object storage for user uploads
Laravel public/storage should be backed by S3 or compatible storage (e.g., MinIO). Do not rely on container filesystem for persistent uploads. Set FILESYSTEM_DISK=s3.
4) Separate workers and autoscale
Run queue and scheduler as independent services. Scale queue workers based on backlog and throughput. Keep one scheduler replica.
5) Healthchecks and readiness
Use Docker healthchecks (as shown) and expose /healthz in Nginx. Or add a Laravel route for deeper health (DB, Redis). Configure your orchestrator to route only ready containers.
6) Config for Next.js standalone output
In next.config.js, set standalone output for smaller runtime images:
/**** nextjs/next.config.js ****/
module.exports = {
output: 'standalone',
reactStrictMode: true,
experimental: { serverActions: true }, // if you use them
};
7) Secure defaults
- Run Node as non-root (already configured).
- Restrict Nginx to only serve necessary paths.
- Disable PHP execute permissions outside necessary dirs if you harden further.
- Keep APP_KEY and DB credentials in a secret manager; inject at deploy time.
Auth and API Integration
Common patterns to connect Next.js and Laravel:
- JWT: Issue tokens from Laravel; Next.js stores httpOnly cookies and forwards Authorization headers to /api.
- Laravel Sanctum: Works well for SPAs on the same top-level domain; ensure SANCTUM_STATEFUL_DOMAINS and SESSION_DOMAIN are set correctly.
- SSO/OAuth: Use Laravel Socialite for providers; NextAuth.js on Next side can call Laravel endpoints or share the same provider.
How to Fix It: Common Pitfalls
1) 502 Bad Gateway from Nginx
Cause: php service not healthy, wrong fastcgi_pass, or wrong document root.
Fix:
- Ensure fastcgi_pass php:9000 matches the service name and port.
- Map Laravel public to /var/www/html/public and ensure index.php exists.
- Check php logs: docker compose logs -f php
2) Permissions on storage and cache
Cause: storage or bootstrap/cache not writable.
Fix: In Dockerfile we chown to www-data. If using bind mounts on Mac/Windows, run:
docker compose exec php sh -lc 'chown -R www-data:www-data storage bootstrap/cache && chmod -R ug+rw storage bootstrap/cache'
3) CORS and cookies not working
Cause: Missing SANCTUM_STATEFUL_DOMAINS or mismatched SESSION_DOMAIN.
Fix: Update .env to list frontend host and port. For production, prefer same-site, same-domain cookies over multiple subdomains if possible.
4) Next.js environment values at runtime
Cause: NEXT_PUBLIC_ vars are inlined at build time. Changing them at runtime has no effect unless designed for it.
Fix: For values that must change at runtime, use server-only env access in Node process (process.env) or an env-injection script at container start.
5) Node modules performance in dev
Cause: bind-mounting node_modules can be slow on Mac/Windows.
Fix: Prefer building the Next.js image and running it inside the container, or use a named volume only for node_modules mounted at the correct path.
Observability and Operations
- Logs: Aggregate Nginx, PHP, and Node logs. In Compose: docker compose logs -f service.
- Metrics: Expose app-level metrics (e.g., Prometheus via Laravel or Node middleware). Use uptime checks on /healthz.
- Backups: Schedule DB backups; for PostgreSQL, use pg_dump; store offsite.
- Zero-downtime: Build images in CI, pull, and rotate containers behind a load balancer with healthchecks.
Scaling Strategy
- Horizontal scale php and frontend first. Ensure sessions are in Redis, not on disk.
- Move DB and Redis to managed services for HA and performance.
- For global latency, front with a CDN; cache Next.js HTML where possible and use incremental static regeneration (ISR) features if your app allows.
Future Outlook
Containers remain a strong default for Laravel + Next.js, even as serverless and edge runtimes grow. You can combine them: run the Next.js frontend as an edge app for static and cached routes, and keep Laravel as a containerized API for complex state, queues, and reporting. For teams at scale, migrate this Compose layout to Kubernetes or ECS with IaC (Terraform), keeping the same image boundaries and healthchecks.
FAQ
Should I run Next.js behind Nginx or expose it directly?
Behind Nginx is simpler here. One entrypoint manages TLS, caching, and routing to Laravel and Next.js. In Kubernetes or with a cloud LB, you can expose both separately and route at the ingress layer.
How do I share auth between Laravel and Next.js?
Use httpOnly cookies with Laravel (Sanctum or JWT) and ensure domain and CORS settings match. Store sessions and CSRF state in Redis. Avoid storing tokens in localStorage.
Can I switch Postgres to MySQL or MariaDB?
Yes. Update DB_CONNECTION, the database image and ports, and install the correct PHP extension (pdo_mysql). Rebuild the php image if extensions change.
How do I add HTTPS locally?
Use mkcert to generate a local CA and certs, then mount them into Nginx and listen on 443. Or run Traefik/Caddy in the stack to terminate TLS automatically for dev domains.
Final Thoughts
You now have a clean Laravel backend + Next.js frontend stack with Docker Compose that works locally and in production. It keeps concerns separate, supports queues and the scheduler, and scales cleanly. Start with this baseline, then refine for your CI/CD, security posture, and cloud provider.
Need help containerizing your Laravel and Next.js app? I design and ship production-grade Docker, CI/CD, and cloud deployments. If you want a review or a turnkey setup, get in touch.