Effortlessly Dockerize Your Laravel & Vue Application: A Step-by-Step Guide
Dockerize your Laravel and Vue.js application with PHP 8.3, MySQL 8.4, Nginx, and Vite hot reloading for a consistent, shareable dev environment.
Every Laravel developer has heard this one: "It works on my machine." You're running PHP 8.3 locally, your teammate has 8.1, and the CI server has something else entirely. Your MySQL version doesn't match production. Node modules behave differently across operating systems. Docker fixes all of this.
But setting up Docker for a Laravel and Vue.js stack isn't always straightforward. You need PHP-FPM, Nginx, MySQL, and a Node container for Vite's hot module replacement, all talking to each other. I've set this up for dozens of client projects, and I'll walk you through the exact configuration I use.
By the end of this guide, you'll have a fully Dockerized Laravel 12 + Vue 3 application with hot reloading, MySQL 8.4, and a production-ready multi-stage build. Everything runs with a single docker compose up.
Why Docker Instead of Laravel Sail?
Before we start, a fair question: why not just use Laravel Sail? Sail is great for getting started quickly. It's a light wrapper around Docker Compose that ships with Laravel. But it has limitations.
Sail generates a fairly opinionated setup that's harder to customize. If you need a specific Nginx config, custom PHP extensions, or want to match your production infrastructure exactly, you'll outgrow Sail fast. I typically start new projects with Sail, then migrate to a custom Docker setup once the project's infrastructure needs become clear.
The setup in this guide gives you full control. You understand every line of your Dockerfile and compose config, which matters when debugging production issues at 2 AM. You can pin exact versions of PHP, MySQL, and Node. You can add custom PHP extensions. And you can replicate your production infrastructure locally with confidence that what works in development will work in staging and production.
Docker also makes onboarding painless. Instead of a 2-page setup guide with "install PHP 8.3, then install MySQL, then configure Nginx...", new team members just run docker compose up and they're coding within minutes. I've cut onboarding time from half a day to under 15 minutes on several client projects.
Project Structure Overview
Here's what we're building. Each service gets its own container:
project-root/
├── docker/
│ ├── nginx.conf
│ └── Dockerfile.node
├── .dockerignore
├── Dockerfile
├── compose.yaml
├── .env
└── ... (Laravel app files)
I keep Docker files in a docker/ subdirectory to avoid cluttering the project root, except for the main Dockerfile and compose.yaml which belong at the top level.
Step 1: Configure Your .env File
Set up your environment variables so the Laravel app connects to the MySQL container. The key detail is DB_HOST=db, which is the service name from our Compose file, not localhost or 127.0.0.1:
APP_NAME=Laravel
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8000
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=laravel_local
DB_USERNAME=laravel
DB_PASSWORD=secret
Step 2: Create the .dockerignore File
This is the step most tutorials skip, and it matters. Without a .dockerignore, Docker copies your entire node_modules directory, vendor folder, and .git history into the build context. That can turn a 30-second build into a 5-minute one.
node_modules
vendor
.git
.env
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*
Think of .dockerignore like .gitignore for your Docker builds. It keeps images small and builds fast.
Step 3: The PHP Dockerfile
This is the heart of your setup. The Dockerfile defines how your Laravel application container is built:
FROM php:8.3-fpm
RUN apt-get update && apt-get install -y \
libpng-dev \
libonig-dev \
libxml2-dev \
libzip-dev \
libfreetype6-dev \
libjpeg62-turbo-dev \
zip \
curl \
unzip \
git \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www
COPY --chown=www-data:www-data . /var/www
USER www-data
EXPOSE 9000
CMD ["php-fpm"]
A few things to notice here. We're using PHP 8.3-FPM, which is the minimum for Laravel 12. The --chown=www-data:www-data flag sets proper file ownership so PHP-FPM can read your application files without running as root. And we clean up apt caches in the same RUN layer to keep the image size down.
I'm pulling Composer from its official image with a multi-stage COPY --from=composer:2 instead of manually downloading it. Cleaner and always gives you the latest stable Composer 2.x.
Step 4: Nginx Configuration
Create docker/nginx.conf to route requests to your Laravel application. Nginx handles static files directly and passes PHP requests to the FPM container:
server {
listen 80;
index index.php index.html;
root /var/www/public;
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.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;
}
location ~ /\.ht {
deny all;
}
client_max_body_size 50M;
}
The fastcgi_pass app:9000 line connects to our PHP container (named app in the Compose file) on port 9000. I also add client_max_body_size 50M because Laravel's default upload handling gets blocked by Nginx's 1MB default long before your PHP config matters. If your app handles large file uploads, you'll want to increase this further.
Step 5: Node.js Dockerfile for Vite
Create docker/Dockerfile.node for your Vue frontend. This container runs the Vite dev server with hot module replacement:
FROM node:22-alpine
WORKDIR /var/www
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
We use Node 22 (the current Active LTS) and the alpine variant to keep the image small. The npm ci command is faster than npm install and ensures reproducible builds from your lockfile.
Step 6: Docker Compose Configuration
Now we tie everything together. Create compose.yaml in your project root. The filename compose.yaml is the modern convention. Docker Compose still supports docker-compose.yml, but the newer name avoids confusion with the deprecated v1 tooling. Similarly, all commands use docker compose (space, no hyphen) instead of the old docker-compose binary:
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: laravel-app
restart: unless-stopped
working_dir: /var/www
volumes:
- ./:/var/www
- ./.env:/var/www/.env
depends_on:
db:
condition: service_healthy
networks:
- app-network
nginx:
image: nginx:alpine
container_name: laravel-nginx
ports:
- "8000:80"
volumes:
- ./:/var/www
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- app-network
node:
build:
context: .
dockerfile: docker/Dockerfile.node
container_name: laravel-node
ports:
- "5173:5173"
restart: unless-stopped
working_dir: /var/www
volumes:
- ./:/var/www
- /var/www/node_modules
networks:
- app-network
db:
image: mysql:8.4
container_name: laravel-mysql
restart: unless-stopped
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
volumes:
- dbdata:/var/lib/mysql
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
dbdata:
driver: local
A few important details here. First, notice there's no version: key at the top. That's obsolete since Docker Compose v2 and will throw a warning if you include it. Second, the db service has a healthcheck that pings MySQL to confirm it's ready before the app container starts (via condition: service_healthy). Without this, your Laravel app might crash on startup because MySQL isn't ready to accept connections yet.
The node service has an anonymous volume for node_modules (/var/www/node_modules). This prevents your host's node_modules from overriding the container's version, which is a common source of "works on my machine" bugs, especially across Mac, Windows, and Linux.
We're using MySQL 8.4, which is the current LTS release. MySQL 8.0 reaches end-of-life in April 2026, so starting new projects on 8.4 is the right call.
Step 7: Configure Vite for Docker HMR
Update your vite.config.js so Vite's hot module replacement works inside Docker. The key is binding to 0.0.0.0 so the dev server is accessible outside the container:
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
server: {
host: '0.0.0.0',
port: 5173,
hmr: {
host: 'localhost',
},
},
plugins: [
vue(),
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
],
})
The server.host: '0.0.0.0' makes Vite listen on all interfaces inside the container. The hmr.host: 'localhost' tells the browser where to connect for hot updates. Without this split configuration, HMR connections will fail because the browser can't reach 0.0.0.0.
Step 8: Build and Run
Build your containers and start everything:
docker compose build
docker compose up -d
Once the containers are running, run your initial Laravel setup:
docker compose exec app composer install
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate
Now visit http://localhost:8000 for your Laravel app and check that Vite HMR is working on port 5173. Edit a Vue component and you should see changes reflected instantly.
To watch container logs in real time: docker compose logs -f. This is incredibly useful for debugging. You'll see PHP errors, Nginx access logs, and MySQL connection info all in one stream. Add a service name to filter: docker compose logs -f app shows only PHP-FPM output.
To stop everything: docker compose down. To stop and remove volumes (fresh database): docker compose down -v.
Production: Multi-Stage Builds
The dev setup above mounts your local files into containers, which is great for development but wrong for production. In production, you want a self-contained image with all dependencies baked in.
Here's a production-ready multi-stage Dockerfile:
# Stage 1: Build frontend assets
FROM node:22-alpine AS frontend
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Install PHP dependencies
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist
# Stage 3: Final production image
FROM php:8.3-fpm AS production
RUN apt-get update && apt-get install -y \
libpng-dev libonig-dev libxml2-dev libzip-dev \
&& docker-php-ext-install pdo_mysql mbstring bcmath gd zip opcache \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /var/www
COPY --from=vendor /app/vendor ./vendor
COPY --from=frontend /app/public/build ./public/build
COPY . .
RUN chown -R www-data:www-data storage bootstrap/cache
USER www-data
EXPOSE 9000
CMD ["php-fpm"]
This produces a much smaller final image because build tools (Node, Composer) aren't included. Only the compiled assets and production PHP dependencies make it into the final layer. I use this same pattern when building APIs that need fast container startup times.
Common Issues and Fixes
I've run into every one of these problems on real projects. Save yourself the frustration and check this list before filing an issue.
MySQL Connection Refused on First Start
The app container starts before MySQL is ready to accept connections. Our healthcheck config handles this, but if you still see errors, add a retry loop in your entrypoint or use depends_on with condition: service_healthy (which we do above).
Vite HMR Not Working
Check three things: your vite.config.js has host: '0.0.0.0', port 5173 is mapped in compose.yaml, and your hmr.host is set to localhost. If you're on Windows with WSL2, you might need to use your machine's IP instead of localhost.
File Permission Issues
If you see "Permission denied" errors, it's usually because files created on your host have different ownership than the www-data user inside the container. Run docker compose exec app chown -R www-data:www-data storage bootstrap/cache to fix it.
Slow Performance on macOS
Docker Desktop on Mac uses a virtualized filesystem that's noticeably slower than native Linux. If builds feel sluggish, add :cached to your volume mounts in compose.yaml (e.g., ./:/var/www:cached) or consider using Docker's synchronized file shares feature.
Connecting to MySQL from Host
Need to query your database with a GUI like TablePlus or DBeaver? Connect to localhost:3306 using the credentials from your .env file. The port mapping in compose.yaml makes this work.
Performance Tips for Production
A few optimizations I apply to every production Docker setup:
Enable OPcache in your PHP container. Add this to your production Dockerfile (we already install the extension):
; docker/php/opcache.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
Copy it into the container with COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini. The validate_timestamps=0 setting tells PHP not to check if files changed, which is correct in production where code doesn't change at runtime.
Use Alpine-based images where possible. php:8.3-fpm-alpine is roughly 80% smaller than the Debian-based variant. The tradeoff is that you'll need apk instead of apt-get for installing packages, and some C libraries need different names. For most Laravel apps, it works fine.
Set sensible PHP limits for your production container. Create a docker/php/php-production.ini with memory_limit=256M, upload_max_filesize=50M, post_max_size=50M, and max_execution_time=30. Copy it into the container alongside your OPcache config. These defaults are too conservative in PHP's base image and will trip you up with file uploads or memory-heavy operations.
Run database migrations as part of deployment, not on container startup. I usually handle this in my CI/CD pipeline with docker compose exec app php artisan migrate --force after deployment. If you're processing background work, make sure your queue workers run in separate containers scaled independently from your web servers.
Frequently Asked Questions
Should I use Docker Compose or Laravel Sail for development?
Use Sail if you want zero configuration and are working on a standard Laravel stack. Use custom Docker Compose if you need to match your production environment, require specific Nginx configs, need custom PHP extensions, or work on a team with strict infrastructure requirements. Most projects I work on start with Sail and graduate to custom Compose within a few months.
How do I run artisan commands inside Docker?
Prefix any command with docker compose exec app. For example: docker compose exec app php artisan migrate, docker compose exec app php artisan tinker, or docker compose exec app composer require some/package. You can also create a shell alias like alias art="docker compose exec app php artisan" to save typing.
Can I use PostgreSQL instead of MySQL?
Yes. Replace the db service image with postgres:17-alpine, change the environment variables to POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD, and update your .env to DB_CONNECTION=pgsql. The rest of the setup stays the same. If you're comparing the two, I covered database optimization strategies that apply to both.
How do I add Redis for caching and queues?
Add a Redis service to your compose.yaml:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
networks:
- app-network
Then update your .env with REDIS_HOST=redis and set CACHE_STORE=redis and QUEUE_CONNECTION=redis. Redis runs great in Docker with almost zero configuration.
How do I handle SSL/HTTPS in Docker?
For local development, you don't need HTTPS. For production, put a reverse proxy like Traefik or Caddy in front of your Nginx container to handle SSL termination. Caddy is the easiest option since it automatically provisions Let's Encrypt certificates. You can also use a base64 encoder to encode certificate data if you need to pass it through environment variables.
Wrapping Up
Docker turns your Laravel + Vue.js development environment from "it works on my machine" into "it works on every machine." The initial setup takes an hour, but it saves you days of debugging environment inconsistencies over the life of a project.
The key decisions that make this setup work: healthy checks on MySQL so your app doesn't crash on cold starts, anonymous volumes for node_modules so cross-platform builds are consistent, and multi-stage production builds that keep your images small.
Start with the dev configuration, get your team onboarded, and then layer in the production multi-stage build when you're ready to deploy. And if you're building something that needs to scale beyond a single server, this Docker setup is the foundation for Kubernetes or any container orchestration platform.
Need help Dockerizing your Laravel application or setting up a production deployment pipeline? Let's talk about your project.
About Hafiz
Senior Full-Stack Developer with 9+ years building web apps and SaaS platforms. I specialize in Laravel and Vue.js, and I write about the real decisions behind shipping production software.
View My Work →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
Building a Full-Stack File Upload System with Laravel, Vue.js, and S3
Build a production-ready file upload system with Laravel, Vue.js drag-and-drop,...
Using Docker to Solve PHP Version Compatibility Issues: A Practical Guide
Deploy Laravel apps requiring newer PHP versions on older servers using Docker,...
The Ralph Wiggum Technique: Let Claude Code Work Through Your Entire Task List While You Sleep
Queue up your Laravel task list, walk away, and come back to finished work. Here...