Building a LAMP Stack with Docker Compose

Manually installing a LAMP stack on a local machine usually means wrestling with OS packages, PHP versions, and MySQL configuration — then doing it all over again on the next machine. With Docker Compose, Nginx, PHP-FPM, and MySQL run as three orchestrated containers, and the whole environment starts with two commands. Because dev machines, test servers, and production all run the same images, the classic "works locally, breaks in production" gap shrinks dramatically. For solo developers there is a bonus: the compose file doubles as a reproducible environment document — a new teammate can clone the repo and run it without a chain of screenshots.

Directory layout and the compose file

Set up the project directory first, keeping your code under www/ and the Nginx config in a dedicated file:

lamp/
├── docker-compose.yml
├── .env
├── nginx/
│   └── default.conf
└── www/
    └── index.php

Here is the core docker-compose.yml. Note that the MySQL passwords and database name are injected through environment variables instead of being hard-coded:

version: '3.8'
services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
      - ./www:/var/www/html
    depends_on:
      - php

  php:
    image: php:8.2-fpm
    volumes:
      - ./www:/var/www/html
    environment:
      - DB_HOST=mysql
      - DB_NAME=${DB_NAME:-appdb}
      - DB_USER=${DB_USER:-appuser}
      - DB_PASS=${DB_PASS:-changeme}

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASS:-rootpass}
      MYSQL_DATABASE: ${DB_NAME:-appdb}
      MYSQL_USER: ${DB_USER:-appuser}
      MYSQL_PASSWORD: ${DB_PASS:-changeme}
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db_data:

Reference: Docker Compose documentation https://docs.docker.com/compose/

The .env file and day-to-day commands

Create a .env file in the same directory, put the real passwords there, and make sure it is in .gitignore:

DB_NAME=mywebsite
DB_USER=webapp
DB_PASS=your_strong_password
MYSQL_ROOT_PASS=another_strong_password

Start and manage the stack:

docker compose up -d          # start all services in the background
docker compose ps             # list container status
docker compose logs -f php    # follow the PHP logs
docker compose down           # stop without deleting data
docker compose down -v        # stop and remove the database volume (careful)

Wiring Nginx to PHP-FPM

The PHP container runs php-fpm, so Nginx must forward requests through FastCGI. Write nginx/default.conf like this:

server {
    listen 80;
    server_name localhost;
    root /var/www/html;
    index index.php index.html;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass php:9000;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

The fastcgi_pass php:9000 line uses the Compose network's service name php rather than an IP address, so container restarts don't break the connection. Also set client_max_body_size in nginx/default.conf, or image/file uploads over the default 1MB get rejected with a 413.

Why these three images

Choosing nginx:alpine over nginx:latest is about footprint: the Alpine build is roughly 50MB, starts faster, and uses less memory, which suits single-host deployments. php:8.2-fpm is the official PHP-FPM image, and pairing it with Nginx's FastCGI forwarding is the standard way to run PHP; to install extensions such as gd or pdo_mysql, layer your own Dockerfile on top. mysql:8.0 data belongs in the named volume db_data so it survives container re-creation — never leave database files in an anonymous directory, or a docker compose down can wipe everything. If you prefer MariaDB, swap the image to mariadb:11 — the parameters stay essentially the same.

What each service does

Service Image Purpose
nginx nginx:alpine Web server and reverse proxy
php php:8.2-fpm Runs the PHP application
mysql mysql:8.0 Relational database

Reference: Nginx Docker Hub https://hub.docker.com/_/nginx ; PHP Docker Hub https://hub.docker.com/_/php

Common problems

  1. PHP cannot reach MySQL. Right after startup MySQL may not be ready, and PHP processes report "connection refused". The healthcheck above helps; retrying a few times in the application code also works. You can additionally set depends_on: condition: service_healthy on the php service.
  2. 502 errors from file permissions. If the ownership of the mounted directory does not match, FPM cannot read the files. Make sure www/ is owned by the user the container expects — chown -R www-data:www-data when needed.
  3. Config changes have no effect. Nginx and PHP configuration changes require a container restart: docker compose restart nginx.
  4. Timezone and encoding drift. Add TZ=Asia/Shanghai (or your region) to environment so log times and database times stay consistent.

Common problems

502 after startup — what now? Check docker compose ps to confirm all three containers are up, then docker compose logs php for errors; the usual suspects are a mistyped service name in fastcgi_pass or a missing index.php in the www directory. Why do code changes take multiple refreshes to show? Mounted code syncs in real time, but PHP-FPM caches opcode; in development, disable opcache or run docker compose restart php. How do I add Redis? Add a redis:7 service under services and connect using the hostname redis — the Compose network is automatically shared. MySQL eats too much memory? Limit the buffer pool with command: --innodb-buffer-pool-size=128M on the mysql service — very effective on small dev machines.

Dev vs production

Development environments want convenience: live code mounts, direct log tailing, and config reloads via container restarts. Production wants the opposite — stability and predictability. Starting from the same compose file is fine, but before going live make at least three changes: pin image to an exact tag (e.g. php:8.2-fpm) instead of following latest; add restart: always and configure log rotation; and fold the database volume and backups into the daily operations plan. To switch between environments, override with docker compose -f docker-compose.yml -f docker-compose.prod.yml — the shared parts are written once.

Security checks before going live

Getting the environment running is only the first step; production robustness comes from a few hard constraints:

  • Change every default password in .env; use strong values in production.
  • Keep the default of not exposing MySQL's 3306 to the host — it only needs to be reachable inside the Docker network.
  • Add restart: always to every service so they come back after a server reboot.
  • For production, follow production Docker Compose deployment and route traffic through an Nginx reverse proxy.

New to containerization? Start with the environment deployment category; if you want the whole LNMP picture in one place, the LNMP setup guide is a good systematic read.