Luke Anderson DevelopmentDockerPHP

Professional Local Web Development Setup with Windows WSL, Linux, Docker & PHP

If you’re still running PHP through XAMPP, WAMP, or Laragon — it’s time to upgrade. Those tools were great in 2015, but in 2026 every serious web developer runs their local environment the way production runs: Linux + Docker. And thanks to WSL2 (Windows Subsystem for Linux), you can do all of that on Windows without giving up your favorite OS.

This guide walks you through building a complete, professional local development setup from scratch:

  • Windows 11/10 as your daily driver
  • WSL2 with Ubuntu 24.04 LTS — a real Linux environment inside Windows
  • Docker installed inside WSL for true containerization
  • A working PHP 8.3 + Nginx + MySQL + phpMyAdmin stack orchestrated with docker-compose

By the end, you’ll have a setup that mirrors production servers exactly, runs faster than any “all-in-one” stack tool, and lets you switch projects with PHP 7.4, 8.1, 8.3 — without conflicts. This is what professional teams use.

📺 Watch the full video walkthrough: https://youtu.be/q4EwcEfTYEE

The video shows every step in real time. Bookmark this guide to follow along or come back later for the exact code.

Why WSL2 + Docker (and Not XAMPP)

Before we install anything, here’s why this setup is worth the small learning curve.

Feature XAMPP / WAMP WSL2 + Docker
Matches production server ❌ Windows ≠ Linux ✅ Same OS, same packages
Run multiple PHP versions ⚠️ Painful ✅ One command, isolated
Switch databases (MySQL 5.7 vs 8.0) ❌ Re-install ✅ Change one line
Onboarding new dev to a project ⚠️ Setup doc ✅ docker-compose up
Speed ⚠️ Slow on Windows ✅ Native Linux speed
Free ✅ ✅
Used by professional teams ❌ ✅

The setup time is about 45 minutes. The payoff is years of headache-free development.

Prerequisites

You need:

  • Windows 11 (or Windows 10 build 19041+)
  • 8 GB RAM minimum (16 GB recommended)
  • At least 10 GB free disk space
  • Admin access to your Windows account
  • Virtualization enabled in BIOS (most modern PCs have this on by default)

That’s it. No paid software, no licenses.

Step 1: Install WSL2 and Ubuntu 24.04

WSL2 lets you run a real Linux kernel inside Windows. Open PowerShell as Administrator and run:

 
 
powershell
wsl --install -d Ubuntu-24.04

This single command does everything: installs WSL2, downloads Ubuntu 24.04 LTS, and sets it up. Reboot when prompted.

After reboot, Ubuntu launches automatically and asks you to create a Linux user:

 
 
Installing, this may take a few minutes...
Please create a default UNIX user account. The username does not need to match your Windows username.
Enter new UNIX username: manu
New password: 
Retype new password: 
passwd: password updated successfully
Installation successful!

Welcome to Ubuntu 24.04 LTS (GNU/Linux 5.15.153.1-microsoft-standard-WSL2 x86_64)

💡 Username tip: Keep it lowercase, no spaces (e.g. manu, not Manu Singh). This is your Linux username, separate from your Windows account.

Verify the install — open Ubuntu from the Start Menu and run:

 
 
bash
lsb_release -a

You should see Ubuntu 24.04 LTS. Welcome to Linux on Windows.

Update Ubuntu before going further

Always update on a fresh install:

 
 
bash
sudo apt update && sudo apt upgrade -y

This pulls the latest security patches. Get coffee — it takes 2-3 minutes.

Step 2: Install Docker Inside WSL Ubuntu

There are two ways to use Docker on Windows:

  1. Docker Desktop (GUI app, easier but uses more RAM)
  2. Native Docker inside WSL (faster, more “real-Linux”, no extra software)

We’ll go with option 2 — it’s what production servers use and it’s lighter on resources.

Add Docker’s official repository

 
 
bash
# Install required packages
sudo apt install -y ca-certificates curl gnupg lsb-release

# Add Docker's GPG key
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
  sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

# Add Docker repo
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

Install Docker Engine

 
 
bash
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

Start Docker and enable it on boot

 
 
bash
sudo systemctl start docker
sudo systemctl enable docker

Verify Docker is working

 
 
bash
sudo docker --version
# Docker version 27.2.0, build 3ab4256

If you see a version number, Docker is installed. ✅

Run Docker without sudo (highly recommended)

Typing sudo before every Docker command gets old. Add your user to the docker group:

 
 
bash
sudo usermod -aG docker $USER

Close and reopen your Ubuntu terminal for this to take effect, then test:

 
 
bash
docker run hello-world

If you see “Hello from Docker!” without using sudo, you’re set.

Step 3: Plan Your Project Structure

Before writing any code, create this folder structure. Open VS Code and create a folder called php-docker-app somewhere convenient (e.g. ~/Projects/php-docker-app).

 
 
php-docker-app/
├── nginx/
│   └── default.conf          # Nginx server config
├── www/
│   └── index.php             # Your PHP application code
├── docker-compose.yml        # Orchestrates all services
└── Dockerfile                # Builds the PHP-FPM image

💡 VS Code + WSL: Install the “WSL” extension by Microsoft. Then from Ubuntu run code . inside your project folder. VS Code opens with full IntelliSense, debugging, and Git support — all running in the Linux side.

Create the folders from your terminal:

 
 
bash
mkdir -p ~/Projects/php-docker-app/{nginx,www}
cd ~/Projects/php-docker-app
code .

Step 4: Create the Dockerfile (PHP-FPM)

The Dockerfile builds a custom PHP container with the extensions your app needs. Create Dockerfile in your project root:

 
 
dockerfile
# Dockerfile
FROM php:8.3-fpm

# Install necessary PHP extensions
RUN apt-get update && apt-get install -y \
    libpng-dev \
    libjpeg62-turbo-dev \
    libfreetype6-dev \
    zip \
    unzip \
    && docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install gd \
    && docker-php-ext-install mysqli pdo pdo_mysql

# Set working directory
WORKDIR /var/www/html

# Copy application source code
COPY ./www /var/www/html

# Set permissions for www-data user
RUN chown -R www-data:www-data /var/www/html

# Install composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# Run composer install only if composer.json exists
RUN if [ -f "composer.json" ]; then composer install --no-dev --optimize-autoloader; fi

# Expose port 9000 for PHP-FPM
EXPOSE 9000

CMD ["php-fpm"]

What each section does:

Section Purpose
FROM php:8.3-fpm Use the official PHP 8.3 FastCGI image as a base
RUN apt-get install... Install image-processing and zip libraries
docker-php-ext-configure gd Configure the GD image library with JPEG + FreeType support
docker-php-ext-install mysqli pdo pdo_mysql Install MySQL drivers — you need these to talk to the database
WORKDIR /var/www/html All commands after this run inside this folder
COPY --from=composer:latest Grab Composer from the official Composer image (no need to install it manually)
EXPOSE 9000 Tells Docker this container listens on port 9000 internally

Step 5: Configure Nginx

Nginx is the web server that handles HTTP requests and passes PHP files to PHP-FPM. Create nginx/default.conf:

 
 
nginx
server {
    listen 80;

    server_name localhost;

    root /var/www/html;
    index index.php index.html index.htm;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    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;
    }
}

Key lines explained:

  • listen 80; — Nginx listens for HTTP requests on port 80 (inside the container)
  • root /var/www/html; — Same path as the Dockerfile’s WORKDIR so they match
  • try_files ... /index.php$is_args$args; — Standard front-controller pattern (works for Laravel, Symfony, WordPress)
  • fastcgi_pass app:9000; — Forwards PHP requests to the app service (defined in docker-compose) on port 9000

The magic word is app — that’s the service name we’ll define in docker-compose.yml. Docker’s internal DNS resolves it automatically.

Step 6: Create docker-compose.yml (The Heart of the Stack)

docker-compose.yml defines all your services (PHP, Nginx, MySQL, phpMyAdmin) and how they connect. Create it in your project root:

 
 
yaml
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: php_app
    restart: unless-stopped
    working_dir: /var/www/html
    volumes:
      - ./www:/var/www/html
    ports:
      - "9000:9000"
    networks:
      - app_network

  nginx:
    image: nginx:1.27.1
    container_name: nginx_server
    restart: unless-stopped
    ports:
      - "8080:80"
    volumes:
      - ./www:/var/www/html
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - app
    networks:
      - app_network

  mysql:
    image: mysql:8.0
    container_name: mysql_db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: app_db
      MYSQL_USER: appuser
      MYSQL_PASSWORD: apppassword
    ports:
      - "3306:3306"
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - app_network

  phpmyadmin:
    image: phpmyadmin/phpmyadmin:5.2.1
    container_name: phpmyadmin
    environment:
      PMA_HOST: mysql
      MYSQL_ROOT_PASSWORD: rootpassword
    ports:
      - "8081:80"
    networks:
      - app_network
    depends_on:
      - mysql

volumes:
  db_data:

networks:
  app_network:
    driver: bridge

What each service does

Service Image Internal Port Browser URL
app Custom PHP 8.3-FPM 9000 (not directly accessible — Nginx talks to it)
nginx nginx:1.27.1 80 http://localhost:8080
mysql mysql:8.0 3306 (used by app — no browser UI)
phpmyadmin phpmyadmin:5.2.1 80 http://localhost:8081

Key concepts

  • networks: app_network — All services share a private network. They can talk to each other using service names (mysql, app) instead of IP addresses.
  • volumes: ./www:/var/www/html — Maps your local www/ folder into the container. Edit files in VS Code → changes appear instantly in the running container. No rebuild needed.
  • db_data named volume — Persists MySQL data even when the container is removed. Without this, your database vanishes on docker-compose down.
  • depends_on — Tells Docker the order to start services. Nginx waits for app; phpMyAdmin waits for mysql.

Step 7: Write a Test PHP App with MySQL Connection

Create www/index.php:

 
 
php
<?php
echo "Welcome To PHP APP";
?>
<br />
<?php
$servername = "mysql";        // The name of the MySQL service in Docker Compose
$username = "root";           // MySQL user from Docker Compose
$password = "rootpassword";   // MySQL password from Docker Compose
$dbname = "app_db";           // MySQL database name from Docker Compose

// Create connection
$conn = new mysqli($servername, $username, $password, $dbname);

// Check connection
if ($conn->connect_error) {
    die("Connection failed: " . $conn->connect_error);
}
echo "Connected successfully to the database";

// Query the database
$result = $conn->query("SELECT VERSION() AS version");
$row = $result->fetch_assoc();
echo "<br />MySQL version: " . $row['version'];

$conn->close();
?>

🔑 Critical concept: Notice $servername = "mysql" — NOT localhost or 127.0.0.1. Inside Docker, each container has its own network identity. To reach the MySQL container, you use its service name as defined in docker-compose.yml.

This trips up almost every beginner. Save the line: service name = hostname inside Docker.

Step 8: Build and Run the Stack

Open your terminal in the project folder and run:

 
 
bash
docker-compose up -d --build

What’s happening:

  • up — Start all services
  • -d — Detached mode (runs in background, returns terminal control)
  • --build — Rebuild the custom app image first (because of our Dockerfile)

You’ll see output like:

 
 
Creating mysql_db     ... done
Creating phpmyadmin   ... done
Creating php_app      ... done
Creating nginx_server ... done

Verify everything is running

 
 
bash
docker ps

You should see all four containers running:

 
 
CONTAINER ID   IMAGE                          NAMES           PORTS
7a1bf3ad7c38   nginx:1.27.1                   nginx_server    0.0.0.0:8080->80/tcp
72fa4898bbe6   test_app                       php_app         0.0.0.0:9000->9000/tcp
1e3737d0fc1b   phpmyadmin/phpmyadmin:5.2.1    phpmyadmin      0.0.0.0:8081->80/tcp
faad8fb4402c   mysql:8.0                      mysql_db        0.0.0.0:3306->3306/tcp

Test it in the browser

URL What you’ll see
http://localhost:8080 “Welcome To PHP APP” + “Connected successfully to the database” + MySQL version
http://localhost:8081 phpMyAdmin login (user: root, password: rootpassword)

🎉 You now have a complete LEMP stack running locally — same as a production server, fully isolated, completely reproducible.

Daily Docker Commands You’ll Use

 
 
bash
# Start the stack
docker-compose up -d

# Stop the stack (containers removed, data preserved in volumes)
docker-compose down

# Stop AND delete database (full reset)
docker-compose down -v

# Rebuild after editing the Dockerfile
docker-compose up -d --build

# Watch logs from all services (live)
docker-compose logs -f

# Watch logs from one service
docker-compose logs -f app

# Open a shell inside the PHP container
docker exec -it php_app bash

# Open a MySQL shell
docker exec -it mysql_db mysql -u root -p

# See what's running
docker ps

# Remove unused images / containers / volumes (free up disk)
docker system prune -a

Common Errors and Fixes

Error Cause Fix
docker: command not found Docker not installed or terminal not reopened after user-group change Reopen terminal; verify with docker --version
Cannot connect to the Docker daemon Docker service not running sudo systemctl start docker
Bind for 0.0.0.0:8080 failed: port is already allocated Port 8080 used by another app Change "8080:80" to "8090:80" in docker-compose.yml
SQLSTATE[HY000] [2002] Connection refused Used localhost instead of service name in PHP Use mysql (the service name), not localhost
MySQL says: Access denied for user 'root' Wrong password or DB not initialized Check MYSQL_ROOT_PASSWORD in compose; run docker-compose down -v to reset
Changes to PHP files don’t appear Volume not mounted Confirm ./www:/var/www/html is under volumes: for both app and nginx
permission denied writing files Wrong owner inside container docker exec -it php_app chown -R www-data:www-data /var/www/html
WSL too slow on file changes Project stored on Windows filesystem (/mnt/c/...) Move project into Linux filesystem (~/Projects/...) — 10× faster

Pro Tips for Day-to-Day Work

1. Keep projects on the Linux filesystem

Wrong: /mnt/c/Users/You/projects/... (Windows side — extremely slow) Right: ~/Projects/... (Linux side — full native speed)

Use \\wsl$\Ubuntu-24.04\home\manu\Projects in Windows Explorer if you need GUI access.

2. Use VS Code’s “Remote – WSL” mode

When VS Code opens a folder inside WSL, it runs the language server, terminal, and extensions on the Linux side. Look for the green badge in the bottom-left corner — WSL: Ubuntu-24.04.

3. Run multiple projects on different ports

Just change the nginx port mapping per project:

 
 
yaml
# Project A
nginx:
  ports:
    - "8080:80"

# Project B
nginx:
  ports:
    - "8090:80"

Both can run simultaneously.

4. Add Xdebug for debugging

Add to your Dockerfile:

 
 
dockerfile
RUN pecl install xdebug \
    && docker-php-ext-enable xdebug

Then breakpoints in VS Code “just work” with the PHP Debug extension.

5. Speed up image rebuilds

Place rarely-changing layers (apt installs) BEFORE frequently-changing layers (COPY source). Docker caches each layer — putting source-copy last means edits to PHP files don’t re-trigger apt-get install.

Quick Reference Cheat Sheet

Setup commands (run once)

 
 
bash
# Install WSL2 + Ubuntu
wsl --install -d Ubuntu-24.04

# Inside Ubuntu — install Docker
sudo apt update && sudo apt upgrade -y
# (See Step 2 for full Docker install commands)
sudo usermod -aG docker $USER

Per-project structure

 
 
project/
├── nginx/default.conf
├── www/index.php
├── docker-compose.yml
└── Dockerfile

Daily workflow

 
 
bash
cd ~/Projects/my-app
docker-compose up -d --build        # Start
# ...edit code in VS Code...
docker-compose logs -f app          # Debug
docker-compose down                 # Stop

Service names → hostnames

Inside Docker, use the service name from docker-compose.yml as the hostname:

  • DB host in PHP: mysql (✅) — not localhost (❌)
  • Cache host in PHP: redis (✅) — not 127.0.0.1 (❌)

Wrapping Up

You now have a production-grade local development environment running on Windows. Same stack as a real Linux server, isolated per project, reproducible by any teammate with one command. No more “works on my machine” — your machine IS the same as the server.

Next steps to level up:

  • Add Redis as a 5th service for caching
  • Add MailHog for testing emails locally without sending them
  • Set up Xdebug for proper step-through debugging
  • Try a Laravel or WordPress project in this stack (it just works — the same docker-compose.yml runs them)

📺 Reminder: If you got stuck anywhere, the full video walkthrough is at https://youtu.be/q4EwcEfTYEE — every screen is shown in real time.

Discussion

Be the first to comment

Leave a comment

Get a quote