
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
"It works on my machine" is the oldest excuse in web development, and it usually means someone shipped a different PHP version, a missing extension, or a mismatched MySQL to production. Learning Docker for beginners ends that argument: you package your Laravel app, its exact PHP runtime, and every dependency into an image that runs identically on your laptop, a teammate's Windows box, and your server — the same reproducibility my DevOps and cloud services are built on. This guide containerizes a Laravel app from scratch — a real multi-stage Dockerfile and a full docker-compose.yml for app, Nginx, MySQL, and Redis, with nothing left as an exercise.
docker-compose.yml wires the app to Nginx, MySQL, and Redis, and docker compose up starts the whole stack.What is the difference between a Docker image and a container?
The single idea that unlocks Docker for beginners is the split between an image and a container. An image is a read-only template: a stacked set of filesystem layers holding your code, PHP 8.4, its extensions, and configuration, built once from a Dockerfile. A container is a running instance of that image — a live, isolated process with its own filesystem view, network, and memory. The relationship mirrors classes and objects in code:
- Image — the blueprint. Immutable, versioned, and shareable via a registry like Docker Hub. Rebuilding gives you a new image; you never edit one in place.
- Container — a process started from an image. You can run ten containers from one image, and each is disposable — stop it, delete it, and the image is untouched.
- Volume — managed storage that lives outside the container. Because a container's writable layer disappears when it is removed, MySQL data and Laravel uploads belong in volumes so they survive rebuilds.
That disposability is the point. When your Laravel container behaves oddly, you throw it away and start a clean one from the same image instead of debugging drift. If you are moving an app from a hand-configured server, the same discipline underpins the DevOps and cloud services I set up for teams: reproducible builds, not pets.
How do you write a multi-stage Dockerfile for Laravel?
A naive Dockerfile installs Composer, Node, and build tools into the final image and ships all of it to production — often 1.2 GB of compilers you never run. A multi-stage build fixes this by using throwaway builder stages for the heavy work and copying only the finished artifacts into a slim runtime. Create a file named Dockerfile in your project root:
# syntax=docker/dockerfile:1
# ---- Stage 1: PHP dependencies (Composer) ----
FROM composer:2.8 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev --no-scripts --no-interaction \
--prefer-dist --optimize-autoloader
# ---- Stage 2: front-end assets (Node) ----
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json vite.config.js ./
RUN npm ci
COPY resources ./resources
COPY --from=vendor /app/vendor ./vendor
RUN npm run build
# ---- Stage 3: runtime (PHP-FPM, slim) ----
FROM php:8.4-fpm-alpine AS runtime
WORKDIR /var/www/html
RUN apk add --no-cache libpng libzip icu \
&& apk add --no-cache --virtual .build-deps \
libpng-dev libzip-dev icu-dev oniguruma-dev \
&& docker-php-ext-install pdo_mysql bcmath gd zip intl opcache \
&& apk del .build-deps
COPY . .
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
RUN chown -R www-data:www-data storage bootstrap/cache
EXPOSE 9000
CMD ["php-fpm"] Read the stages top to bottom. The vendor stage resolves Composer packages from your committed composer.lock. The assets stage compiles Vite bundles with Node. The runtime stage starts from php:8.4-fpm-alpine — a few dozen megabytes — installs only the runtime PHP extensions Laravel needs, then copies the built vendor/ and public/build/ from the earlier stages. Node, Composer, and the C compilers never reach the final image, so it stays small and has far less to patch for security.
How do you write a docker-compose.yml for Laravel with Nginx, MySQL, and Redis?
Your PHP-FPM container serves nothing by itself — FPM speaks FastCGI, not HTTP. In development you run several containers together: the Laravel app (PHP-FPM), an nginx web server that forwards requests to it, mysql for the database, and redis for cache, sessions, and queues. Docker Compose declares all of them in one file and puts them on a shared network so they reach each other by service name. Save this as docker-compose.yml:
services:
app:
build:
context: .
target: runtime
volumes:
- .:/var/www/html
- /var/www/html/vendor
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
environment:
DB_HOST: mysql
DB_DATABASE: laravel
DB_USERNAME: laravel
DB_PASSWORD: secret
REDIS_HOST: redis
networks:
- appnet
nginx:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- .:/var/www/html
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- appnet
mysql:
image: mysql:8.4
environment:
MYSQL_DATABASE: laravel
MYSQL_USER: laravel
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: secret
volumes:
- dbdata:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-psecret"]
interval: 5s
retries: 10
networks:
- appnet
redis:
image: redis:7-alpine
networks:
- appnet
volumes:
dbdata:
networks:
appnet:
driver: bridge Three parts of that file matter most for beginners:
- Service names are hostnames. Because every service joins the
appnetbridge network, the app reaches the database at hostmysqland the cache atredis— never127.0.0.1. SetDB_HOST=mysqlandREDIS_HOST=redisin your.env. - Volumes serve two jobs. The named
dbdatavolume persists MySQL across rebuilds, while the anonymous/var/www/html/vendorvolume stops your local (possibly empty) vendor folder from shadowing the one baked into the image. - Healthchecks order startup.
depends_on: condition: service_healthymakes the app wait until MySQL actually answers, not merely until its container exists — which prevents the classic "connection refused" on first boot.
The Nginx site config (docker/nginx.conf) points the document root at Laravel's public/ and passes PHP requests to the app container on port 9000:
server {
listen 80;
server_name localhost;
root /var/www/html/public;
index index.php;
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 $realpath_root$fastcgi_script_name;
}
} How do you run the app and Artisan commands inside the container?
With the three files in place, one command builds every image and starts the whole stack. From your project root:
docker compose up -d --build # build images and start in the background
docker compose ps # confirm app, nginx, mysql, redis are up Note the syntax: modern Docker uses docker compose (a built-in plugin, two words), not the old standalone docker-compose. Your site is now live at http://localhost:8080. Because Artisan and Composer belong to the app's PHP runtime, you run them inside the container with docker compose exec app rather than on your host:
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate --seed
docker compose exec app php artisan config:cache
docker compose exec app composer require laravel/horizon The migrate command connects over the Docker network to the mysql service, and the schema lands in the persistent dbdata volume, so it survives docker compose down and the next rebuild. When you are done, docker compose down stops and removes the containers but keeps named volumes; add -v only when you deliberately want to wipe the database. This exact workflow — build once, run migrations inside the container, deploy the same image everywhere — is what I reuse across the DevOps case studies in my portfolio, and it pairs naturally with an automated GitLab CI/CD pipeline for Laravel that builds the image and ships it on every push.
A quick troubleshooting checklist
- Permission denied on storage or logs — the app writes as
www-data; the Dockerfile alreadychownsstorageandbootstrap/cache, so rebuild if you added those after first run. - "Connection refused" to MySQL — check
DB_HOSTismysql(the service name), notlocalhost. - Old code still served — you cached config into the image; run
docker compose exec app php artisan config:clearor rebuild.
Why does multi-stage keep the Docker image small, and why does that matter?
Image size is not vanity. A single-stage build that keeps Composer, Node, npm's cache, and the C toolchain can exceed a gigabyte; the multi-stage runtime above lands near 90 MB. Smaller images pull and deploy faster, cost less to store, and — most importantly — expose a smaller attack surface, because every build tool you leave in a production image is one more package that can carry a vulnerability. The rule of thumb: build tools live in builder stages, and the final stage contains only what the app needs to run. That is the whole payoff of learning Docker for beginners the right way — reproducible, lean images you can trust in production.
Conclusion
You now have the full picture: images versus containers, a multi-stage PHP 8.4 Dockerfile that stays lean, a docker-compose.yml that wires Laravel to Nginx, MySQL, and Redis, and the docker compose exec workflow for running migrations and Artisan inside the container. Start by containerizing one project this week, then push the same image through CI so what you test locally is exactly what ships. If you want your Laravel Docker setup built, hardened, and audited for production, get in touch or explore my DevOps and cloud services to see how a containerized stack fits your team.