Adiie9
Cloud and DevOps

Deploying Modern Stacks: Laravel and Next.js Architecture with Docker Compose

Adeel Ahmed

Adeel Ahmed

IT & Business Enablement Leader

·October 9, 2026·5 min read
Deploying Modern Stacks: Laravel and Next.js Architecture with Docker Compose

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

  1. Generate Laravel app key inside the php container.
 docker compose up -d db redis
 docker compose run --rm php php artisan key:generate
 
  1. Run initial migrations and seed if needed.
 docker compose run --rm php php artisan migrate --seed
 
  1. 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.

Share this article

Adeel Ahmed

IT & Business Enablement Leader

IT & Business Enablement Leader and Full Stack Developer with 15+ years of experience delivering scalable, high-performance digital solutions across Pakistan and the UAE. Specialising in React, Next.js, AI integrations, and workflow automation.

You might also like