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:
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, notManu Singh). This is your Linux username, separate from your Windows account.
Verify the install — open Ubuntu from the Start Menu and run:
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:
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:
- Docker Desktop (GUI app, easier but uses more RAM)
- 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
# 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
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
Start Docker and enable it on boot
sudo systemctl start docker
sudo systemctl enable docker
Verify Docker is working
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:
sudo usermod -aG docker $USER
Close and reopen your Ubuntu terminal for this to take effect, then test:
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:
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
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 /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:
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’sWORKDIRso they matchtry_files ... /index.php$is_args$args;— Standard front-controller pattern (works for Laravel, Symfony, WordPress)fastcgi_pass app:9000;— Forwards PHP requests to theappservice (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:
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 localwww/folder into the container. Edit files in VS Code → changes appear instantly in the running container. No rebuild needed.db_datanamed volume — Persists MySQL data even when the container is removed. Without this, your database vanishes ondocker-compose down.depends_on— Tells Docker the order to start services. Nginx waits forapp; phpMyAdmin waits formysql.
Step 7: Write a Test PHP App with MySQL Connection
Create www/index.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"— NOTlocalhostor127.0.0.1. Inside Docker, each container has its own network identity. To reach the MySQL container, you use its service name as defined indocker-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:
docker-compose up -d --build
What’s happening:
up— Start all services-d— Detached mode (runs in background, returns terminal control)--build— Rebuild the customappimage 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
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
# 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:
# 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:
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)
# 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
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(✅) — notlocalhost(❌) - Cache host in PHP:
redis(✅) — not127.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.ymlruns them)
📺 Reminder: If you got stuck anywhere, the full video walkthrough is at https://youtu.be/q4EwcEfTYEE — every screen is shown in real time.
Be the first to comment