Docker for Beginners: Containerize a Laravel App from Scratch (2026)

Khimananda Oli 9 min read Database
Docker for Beginners: Containerize a Laravel App from Scratch (2026)

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.

Dockerfilethe recipebuild stepsImageimmutableapp + runtimeContainer Arunning processContainer Bsame imageVolumepersists dataoutlives container
The core Docker concepts for beginners: a Dockerfile builds an image, the image runs as many containers, and a volume keeps data alive outside any single container.

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.

Stage 1: vendorcomposer installoutputs vendor/Stage 2: assetsnpm run buildoutputs public/build/Stage 3: runtimephp:8.4-fpm-alpine (slim)+ copied vendor/+ copied public/build/= final image ~90 MBCOPY --frombuild toolsdiscarded
Multi-stage build layers: the Composer and Node stages produce artifacts, the runtime copies only those, and the bulky build tools are thrown away — keeping the Laravel image small.

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:

  1. Service names are hostnames. Because every service joins the appnet bridge network, the app reaches the database at host mysql and the cache at redis — never 127.0.0.1. Set DB_HOST=mysql and REDIS_HOST=redis in your .env.
  2. Volumes serve two jobs. The named dbdata volume persists MySQL across rebuilds, while the anonymous /var/www/html/vendor volume stops your local (possibly empty) vendor folder from shadowing the one baked into the image.
  3. Healthchecks order startup. depends_on: condition: service_healthy makes 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 already chowns storage and bootstrap/cache, so rebuild if you added those after first run.
  • "Connection refused" to MySQL — check DB_HOST is mysql (the service name), not localhost.
  • Old code still served — you cached config into the image; run docker compose exec app php artisan config:clear or 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.

Frequently Asked Questions

An image is a read-only template built from a Dockerfile that holds your code, runtime, and dependencies. A container is a running instance of that image — a live, isolated process. One image can spawn many containers, and deleting a container leaves the image untouched.

Docker packages a Laravel app with its exact PHP version, extensions, and services like MySQL and Redis into containers that run identically everywhere. It removes "works on my machine" problems, standardises onboarding, and lets the same image you test locally run in production.

On Windows and macOS, Docker Desktop is the easiest way to get the Docker engine and the compose plugin. On Linux you can install Docker Engine and the compose plugin directly. Either way you get the docker compose command used throughout this guide.

A multi-stage build runs heavy tools like Composer and Node in throwaway builder stages, then copies only the finished vendor and asset files into a slim PHP-FPM runtime. The build tools never reach the final image, so it stays small, faster to deploy, and safer.

A single-stage image carrying Composer, Node, and compilers can exceed 1 GB. A multi-stage PHP 8.4-FPM Alpine runtime that copies in only the built artifacts typically lands around 90 to 150 MB, depending on the PHP extensions your app requires.

docker compose (two words) is the modern plugin built into the Docker CLI and is the current standard. docker-compose (hyphenated) is the older standalone Python tool, now deprecated. Use docker compose; the syntax in the YAML file is otherwise the same.

Use docker compose exec app php artisan migrate. The exec command runs inside the already-running app container, where PHP and your dependencies live. Running artisan on your host instead would use a different PHP version and cannot reach the mysql service over the Docker network.

Set DB_HOST to the compose service name — mysql — not localhost or 127.0.0.1. Containers on the same Docker network resolve each other by service name, so the app reaches the database container at the hostname mysql on the standard port 3306.

Because a container's writable layer is discarded when it is removed. Mount a named volume such as dbdata to /var/lib/mysql so MySQL data lives outside the container. It then survives docker compose down and rebuilds unless you explicitly pass the -v flag.

Volumes are Docker-managed storage that persists independently of any container. You need them for anything that must outlive a container — database files, uploaded media, or logs. Application code, by contrast, is baked into the image and does not need a volume in production.

Yes. PHP-FPM speaks FastCGI, not HTTP, so it cannot serve browsers directly. Nginx accepts HTTP requests, serves static files, and forwards PHP requests to the FPM container over port 9000. The two run as separate services in your docker-compose.yml.

Redis gives Laravel a fast in-memory store for cache, sessions, and queues. Running it as its own container keeps that state out of the app container, so the app stays stateless and disposable while cached data and queued jobs persist independently.

Yes. The Dockerfile installs from these lock files to guarantee identical dependency versions on every build. Commit both and keep vendor and node_modules gitignored, since the build regenerates them inside the image from the locked versions.

Build the runtime target once and reuse that image everywhere. In development, compose mounts your source for live editing; in production you ship the built image without host mounts and provide environment variables and secrets externally, so the tested artifact is exactly what runs.

Use a currently supported release such as php:8.4-fpm-alpine, and match the same version across your builder and runtime stages and any CI pipeline. Matching versions avoids extension and cached-config surprises between your local build and the live server.