Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Docker for Beginners: Containerize a Laravel App from Scratch

By Kokil Thapa | Last reviewed: August 2026

Docker for Beginners: Containerize a Laravel App from Scratch is the most reliable way to eliminate "works on my machine" failures when building modern PHP applications. Instead of installing PHP 8.4, Nginx, and MySQL directly on your host OS, you define the entire stack as code that runs identically on every developer's laptop and production server. This guide walks you through building a complete, functional Laravel 12 development environment using Docker Compose, based on patterns I use daily for client projects ranging from legal-tech portals to eCommerce platforms.

Why should you learn Docker for Beginners: Containerize a Laravel App from Scratch?

When you start working as a Laravel developer in Nepal or remotely for international clients, environment inconsistency becomes your primary bottleneck. A project running perfectly on macOS with Homebrew PHP often breaks on a colleague's Ubuntu machine or fails during deployment because the server lacks a specific extension like bcmath or intl. Containerization solves this by packaging dependencies into immutable images defined in version control.

In practice, I've seen teams waste days debugging queue workers that behaved differently locally versus staging because one environment had Redis 7.x and the other had 6.x. With Docker, the exact Redis version is pinned in your compose file. For legal-tech projects handling sensitive documents or eCommerce sites processing payments via eSewa or Khalti, this reproducibility isn't just convenient—it prevents security regressions caused by ad-hoc server tweaks. You gain confidence that code passing tests locally will behave identically when deployed via CI/CD pipelines.

Traditional Local SetupHost PHP + ExtensionsGlobal Composer / NodeShared MySQL / RedisVersion Conflicts • PollutionHard to Reproduce BugsDocker ContainerizedPHP 8.4-FPM ContainerNginx ContainerMySQL 8.4 + Redis 7.4Isolated • Version PinnedIdentical Dev & Prod
Traditional host-based development creates dependency conflicts, while Docker for Beginners: Containerize a Laravel App from Scratch provides isolated, reproducible environments

How do you configure docker-compose.yml for Laravel 12?

The docker-compose.yml file orchestrates your entire stack. For Laravel 12 running on PHP 8.4, you need at minimum three services: app (PHP-FPM), nginx, and mysql. Create this file in your Laravel project root alongside your existing artisan and composer.json.

<?php
# docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    image: laravel-app:8.4
    container_name: laravel_app
    restart: unless-stopped
    working_dir: /var/www/html
    volumes:
      - .:/var/www/html
      - ./docker/php/local.ini:/usr/local/etc/php/conf.d/local.ini
    networks:
      - laravel_net
    depends_on:
      - mysql
      - redis

  nginx:
    image: nginx:1.27-alpine
    container_name: laravel_nginx
    restart: unless-stopped
    ports:
      - "8080:80"
    volumes:
      - .:/var/www/html
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
    networks:
      - laravel_net
    depends_on:
      - app

  mysql:
    image: mysql:8.4-lts
    container_name: laravel_mysql
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: laravel
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_USER: laravel
      MYSQL_PASSWORD: secret
    ports:
      - "3306:3306"
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - laravel_net

  redis:
    image: redis:7.4-alpine
    container_name: laravel_redis
    restart: unless-stopped
    ports:
      - "6379:6379"
    networks:
      - laravel_net

networks:
  laravel_net:
    driver: bridge

volumes:
  db_data:
    driver: local

Understanding volume mounts and persistence

The bind mount .:/var/www/html syncs your local codebase into the container in real time. Edit a Blade template on your host, refresh the browser, see changes instantly—no rebuild needed. However, database files must use a named volume (db_data) instead of a bind mount. If you bind-mount MySQL data directly to your host filesystem, permission mismatches between Linux containers and macOS/Windows hosts will corrupt your database. Named volumes are managed by Docker and avoid this entirely.

For Laravel applications handling uploads—like document storage in legal-tech portals or product images in WooCommerce stores—you should also add a named volume for /var/www/html/storage/app/public to persist uploaded files across container restarts. Bind-mounting the entire project is fine for code, but mutable application data belongs in named volumes.

What goes in the PHP Dockerfile for Laravel?

Laravel 12 requires PHP 8.2 minimum, but PHP 8.4 is the current stable release in 2026 and offers meaningful performance improvements. The official php:8.4-fpm-bookworm base image includes Debian Bookworm, which provides up-to-date system libraries needed for PDF generation, image processing, and internationalization.

# docker/php/Dockerfile
FROM php:8.4-fpm-bookworm

ARG WWWGROUP=1000
ARG WWWUSER=1000

RUN apt-get update && apt-get install -y \
    git curl zip unzip libpng-dev libjpeg62-turbo-dev \
    libwebp-dev libfreetype6-dev libonig-dev libxml2-dev \
    libzip-dev libicu-dev libmagickwand-dev \
    && docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
    && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd \
        intl zip opcache xml \
    && pecl install redis imagick \
    && docker-php-ext-enable redis imagick \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer

RUN groupadd --force -g $WWWGROUP sail \
    && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u $WWWUSER sail

USER sail
WORKDIR /var/www/html

Why these specific extensions matter

  • bcmath: Required for precise financial calculations in eCommerce order totals and tax computations. Floating-point math causes rounding errors that break invoice reconciliation.
  • intl: Needed for locale-aware formatting, transliteration, and ICU-based collation. Legal-tech portals serving Nepali-language content depend on this for proper sorting and date formatting.
  • gd / imagick: Image manipulation for thumbnails, watermarks, and document previews. Imagick supports more formats than GD alone.
  • redis: Queue drivers, session storage, and caching. Laravel queues backed by Redis handle background jobs reliably without losing messages during deployments.
  • opcache: Non-negotiable for performance. Precompiles PHP bytecode so each request doesn't re-parse source files. Enable it in local.ini with opcache.enable=1 and opcache.revalidate_freq=0 for development.
php:8.4-fpm-bookworm BaseSystem Libraries (libpng, libicu, libmagick)PHP Extensions (bcmath, intl, gd, redis, opcache)Composer 2.7 Binary InjectionNon-root User (sail) + Workdir
PHP Dockerfile layers for Laravel 12: base image → system deps → PHP extensions → Composer → non-root user configuration

How do you configure Nginx for Laravel inside Docker?

Nginx serves as the reverse proxy forwarding HTTP requests to PHP-FPM via FastCGI. The default Nginx config doesn't understand Laravel's front-controller pattern. Create docker/nginx/default.conf with proper try_files directives:

# docker/nginx/default.conf
server {
    listen 80;
    index index.php index.html;
    root /var/www/html/public;
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
        fastcgi_buffer_size 16k;
        fastcgi_buffers 4 16k;
    }
    location ~ /\.ht {
        deny all;
    }
}

Critical detail: fastcgi_pass app:9000 uses the Docker service name app as hostname. Docker Compose automatically resolves service names via internal DNS. Never hardcode IP addresses—they change on every restart. Also note the root points to /public, not the project root. Exposing the project root leaks .env, composer.json, and other sensitive files. I've audited client servers where misconfigured web roots exposed database credentials publicly.

What common mistakes break Laravel Docker setups?

MistakeSymptomFix
Running containers as rootPermission denied on storage/logs, cache files owned by rootCreate non-root user in Dockerfile matching host UID/GID
Bind-mounting MySQL data dirDatabase corruption, startup failures on macOS/WindowsUse named volume db_data for /var/lib/mysql
Missing PHP extensionsClass 'IntlDateFormatter' not found, bcmath errorsInstall all Laravel-required extensions in Dockerfile
No .dockerignore fileSlow builds, vendor/node_modules copied into imageAdd vendor/, node_modules/, .git/, storage/*.log
Hardcoded DB_HOST=localhostConnection refused from app containerSet DB_HOST=mysql (service name) in .env
Opcache disabled in devSlow page loads, 2-3x response timesEnable opcache with revalidate_freq=0 for instant reloads

The permission issue deserves emphasis. When PHP-FPM runs as root inside the container, files created in storage/ become root-owned on your host. Subsequent artisan commands run as your user fail with permission errors. Always create a non-root user in the Dockerfile with UID matching your host user. On Linux, check your UID with id -u; on macOS/Windows with Docker Desktop, UID 1000 typically maps correctly.

Container Issue DetectedPermission Error on storage/?YESNOAdd non-root userMatch host UID/GIDCheck DB_HOST=.envUse service name, not localhostRebuild: docker compose buildRestart: docker compose up -dResolved ✓Resolved ✓
Troubleshooting decision tree for common Laravel Docker permission and database connection problems

How do you run Artisan and Composer commands in Docker?

You cannot run php artisan migrate directly on your host when using Docker—the host PHP version may differ, and it can't reach the containerized MySQL. Instead, execute commands inside the running app container:

# Install dependencies
docker compose exec app composer install

# Generate application key
docker compose exec app php artisan key:generate

# Run migrations
docker compose exec app php artisan migrate:fresh --seed

# Clear caches
docker compose exec app php artisan optimize:clear

# Start queue worker (for testing)
docker compose exec app php artisan queue:work redis --tries=3

For frequent commands, create a shell alias or Makefile target. Typing make migrate beats remembering the full docker compose syntax. In production deployments via Deployer or GitLab CI, these same commands run inside containers during release hooks—your local workflow mirrors deployment exactly. This alignment prevents surprises when CI/CD pipelines execute migrations or cache clearing steps.

Handling first-time setup

  1. Clone repository and copy .env.example to .env
  2. Update DB_HOST=mysql, REDIS_HOST=redis, CACHE_STORE=redis, QUEUE_CONNECTION=redis
  3. Run docker compose up -d --build to build images and start services
  4. Execute docker compose exec app composer install
  5. Generate key: docker compose exec app php artisan key:generate
  6. Migrate and seed: docker compose exec app php artisan migrate:fresh --seed
  7. Visit http://localhost:8080 to verify the application loads

If you encounter slow Composer installs, add a persistent cache volume mounted to /tmp/composer-cache in the app service. This avoids re-downloading packages on every container recreation. For teams in Nepal with limited bandwidth, this optimization saves significant time during onboarding.

Conclusion

Mastering Docker for Beginners: Containerize a Laravel App from Scratch gives you a portable, reproducible development environment that eliminates configuration drift between local machines and production servers. The investment in writing a proper Dockerfile and docker-compose.yml pays dividends every time you onboard a new developer, debug a staging issue, or deploy with confidence. Start with the PHP 8.4 + Nginx + MySQL 8.4 stack outlined here, adapt extensions to your specific project needs, and treat infrastructure as first-class code alongside your application logic.

If you need help setting up containerized Laravel environments for your team or want to audit an existing Docker configuration for production readiness, reach out to discuss your project requirements.

Frequently Asked Questions

You need Docker Engine 24+, Docker Compose V2, and a multi-stage Dockerfile targeting PHP 8.2 or higher with Nginx and MySQL 8.0 services defined in compose.yaml.

Locally it is free. Production VPS hosting starts around Rs 1,500 per month (~USD 11), whereas managed Laravel cloud services typically begin at USD 12–20 monthly excluding bandwidth overages.

Skip Docker if you are on tight deadlines learning Laravel basics, lack Linux CLI comfort, or deploy to shared cPanel hosts where container runtime support is unavailable or restricted.

In my experience working on production Laravel applications, local environment drift causes deployment failures that Laragon cannot prevent. Docker guarantees your local PHP 8.4, Redis 7.x, and MySQL 8.4 versions match production exactly. This eliminates "works on my machine" bugs when deploying via Deployer 7 or GitLab CI. The initial setup takes longer than clicking installers, but debugging time drops significantly once configured correctly for teams collaborating across different operating systems.

Bind mount the host storage/app directory to /var/www/html/storage/app inside the container using named volumes in compose.yaml. Without this, every container restart deletes user uploads, logs, and cached views. On projects like Nepal Gift Card, I always define explicit volume paths rather than anonymous mounts so backup scripts can access files directly from the host filesystem without entering containers. Set proper ownership with chown -R www-data:www-data during image build to avoid permission errors during file writes.

The most frequent issue is storage/framework/cache and bootstrap/cache directories being owned by root instead of www-data. Fix this by adding RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache in your Dockerfile after copying application files. During development, bind-mounted volumes inherit host permissions which often conflict with container users. I have encountered this repeatedly on Ubuntu hosts where the developer UID differs from the container www-data UID 33, requiring explicit USER directives or entrypoint permission fixes.

Execute docker compose exec app php artisan migrate:fresh --seed only in development environments. For production deployments through GitLab CI pipelines, run migrations as a separate pipeline stage before swapping symlinks, never inside long-running web containers. Always wrap migration commands in database transactions and test rollback scripts first. On legal-tech portals handling sensitive client data, I validate schema changes against staging databases before applying them to production containers to prevent accidental data loss during automated deployments.

Yes, install Xdebug 3.x in your PHP-FPM image and configure xdebug.client_host=host.docker.internal with mode=debug. Map port 9003 in compose.yaml and set path mappings in VS Code launch.json to translate container paths to host paths. Breakpoints work identically to native development once configured. In practice, debugging containerized apps requires understanding network namespaces and volume mounts, but provides identical behavior to production environments unlike local PHP installations with different extensions or configuration values.

Never commit .env files into images. Use Docker secrets for production or pass variables through compose.yaml environment section referencing host env vars. During builds, use ARG for non-sensitive defaults only. On client projects involving payment gateways like eSewa or Khalti, I store API keys exclusively in runtime-injected secrets rather than baked-in layers. Inspect built images with docker history to verify no credentials leaked into intermediate layers, as each layer remains accessible even after deletion in final stages.

Use MySQL 8.0 LTS or MariaDB 10.11 for broad compatibility with Laravel 12's schema builder and Eloquent features. PostgreSQL 16 works equally well if your application uses JSON columns or advanced indexing. Avoid MySQL 8.4 until Laravel officially validates compatibility, as some ORM edge cases around authentication plugins and reserved keywords still surface. On eCommerce platforms like Petals Nepal, we standardized on MySQL 8.0 across Docker and production to ensure migration consistency and avoid subtle query planner differences between versions.

Use multi-stage builds separating composer install and npm ci from the final runtime image. Install only production dependencies with --no-dev flags, clear caches with php artisan optimize and npm cache clean, and remove documentation directories. Base images like php:8.4-fpm-alpine reduce size significantly over debian variants. On real deployments, optimized Laravel images dropped from 1.2GB to under 350MB by excluding dev tooling and leveraging Alpine. Smaller images pull faster during zero-downtime deploys and reduce attack surface by removing unnecessary binaries.

Node.js memory limits in containers default lower than host systems, causing webpack or Vite processes to crash silently. Increase memory with NODE_OPTIONS="--max-old-space-size=2048" in your Dockerfile RUN command or compose.yaml environment. Also verify node_modules isn't bind-mounted over container-installed packages, which mixes host and container architectures. I have seen this repeatedly when developers mount entire project directories including node_modules, overriding Linux-amd64 binaries with macOS-arm64 equivalents. Always let containers install their own dependencies during image build.

Configure vite.config.js server.host to '0.0.0.0' and server.hmr.host to 'localhost' so the dev server binds to all interfaces while browsers connect via host ports. Expose port 5173 in compose.yaml and ensure APP_URL matches your browser-accessible hostname. Without these settings, HMR websocket connections fail because containers cannot resolve localhost back to the host machine. On Vue.js frontends integrated with Laravel Blade, this configuration enables instant feedback loops identical to native development while maintaining container isolation for backend services.

Yes, but only if you provide complete documentation and automated deployment scripts. Many Nepali businesses operate with small teams lacking dedicated ops staff, making complex orchestration unsustainable. I recommend starting with single-server Docker Compose setups deployed via Deployer 7 rather than Kubernetes. For sister sites like notarykathmandu.com and translationnepal.com sharing EC2 infrastructure, this approach keeps operational overhead manageable while providing environment consistency. Budget Rs 3,000–5,000 monthly for adequate VPS resources running multiple containerized Laravel apps reliably.

macOS Docker file I/O is notoriously slow due to VirtioFS overhead. Enable VirtioFS in Docker Desktop settings or switch to WSL2 backend on Windows. For heavy file operations, use named volumes instead of bind mounts for vendor and node_modules directories. Cache aggressively with Redis 7.x containers and enable OPcache with validate_timestamps=0 in production images. In my experience, these optimizations reduced page load times from 4 seconds to under 800ms for content-heavy legal portals. Consider Mutagen for bidirectional sync if native performance remains unacceptable during active development.

Share this article

Quick Contact Options
Choose how you want to connect me: