Table of Contents
Introduction
To *containerize* an application refers to the process of adapting an application and its components in order to be able to run it in lightweight environments known as containers. Such environments are isolated and disposable, and can be leveraged for developing, testing, and deploying applications to production.
In this guide, we'll use Docker Compose to containerize a Laravel application for development. When you're finished, you'll have a demo Laravel application running on three separate service containers:
- An
appservice running PHP7.4-FPM; - A
dbservice running MySQL 8.0; - An
nginxservice that uses theappservice to parse PHP code before serving the Laravel application to the final user.
[info] Deploy your frontend applications from GitHub using an app platform. Let the cloud provider focus on scaling your app.
Key Takeaways:
- Containerization makes development predictable: Running Laravel in Docker ensures consistent environments across machines, removing "it works on my computer" issues and making collaboration easier.
- Separation of services improves maintainability: Splitting PHP, MySQL, and Nginx into separate containers helps isolate problems, simplifies updates, and mirrors real-world production setups.
- Environment variables are central to flexible configuration: Using a
.envfile allows developers to change database credentials and other settings without touching application code. - Custom Docker images improve control and security: Building your own PHP image ensures you install only what your project needs, keeping the environment lightweight and secure.
- File permissions must match container users: Setting correct ownership and permissions for Laravel's writable directories prevents runtime errors and supports smooth local development.
- Using override files supports multiple environments:
docker-compose.override.ymlenables local tweaks—like live reloading—without altering production configurations. This keeps development and deployment cleanly separated. - Troubleshooting starts with understanding container behavior: Knowing how to inspect logs, check running services, and test connectivity is key to resolving most issues quickly in containerized setups.
- A Docker-based setup scales with your workflow: Once built, the same configuration can power local, staging, or production environments, creating a stable, reusable foundation for future Laravel projects.
Prerequisites
- Access to an Ubuntu local machine or development server as a non-root user with sudo privileges. If you're using a remote server, it's advisable to have an active firewall installed. To set these up, please refer to our Initial Server Setup Guide for Ubuntu.
- Docker installed on your server, following Steps 1 and 2 of How To Install and Use Docker on Ubuntu.
- Docker Compose installed on your server, following Step 1 of How To Install and Use Docker Compose on Ubuntu.
Step 1 — Obtaining the Demo Application
To get started, we'll fetch the demo Laravel application from its Github repository. We're interested in the tutorial-01 branch, which contains the basic Laravel application we've created in the How To Install and Configure Laravel with Nginx on Ubuntu 22.04 (LEMP) guide.
To obtain the application code that is compatible with this tutorial, download release tutorial-1.0.1 to your home directory with:
cd ~
curl -L https://github.com/do-community/travellist-laravel-demo/archive/tutorial-1.0.1.zip -o travellist.zip
We'll need the unzip command to unpack the application code. In case you haven't installed this package before, do so now with:
sudo apt update
sudo apt install unzip
Now, unzip the contents of the application and rename the unpacked directory for easier access:
unzip travellist.zip
mv travellist-laravel-demo-tutorial-1.0.1 <^>travellist-demo<^>
Navigate to the travellist-demo directory:
cd travellist-demo
In the next step, we'll create a .env configuration file to set up the application.
Step 2 — Setting Up the Application's .env File
The Laravel configuration files are located in a directory called config, inside the application's root directory. Additionally, a .env file is used to set up environment-dependent configuration, such as credentials and any information that might vary between deploys. This file is not included in revision control.
[warning] Warning: The environment configuration file contains sensitive information about your server, including database credentials and security keys. For that reason, you should never share this file publicly.
The values contained in the .env file will take precedence over the values set in regular configuration files located at the config directory. Each installation on a new environment requires a tailored environment file to define things such as database connection settings, debug options, application URL, among other items that may vary depending on which environment the application is running.
We'll now create a new .env file to customize the configuration options for the development environment we're setting up. Laravel comes with an example.env file that we can copy to create our own:
cp .env.example .env
Open this file using nano or your text editor of choice:
nano .env
The current .env file from the travellist demo application contains settings to use a local MySQL database, with 127.0.0.1 as database host. We need to update the DB_HOST variable so that it points to the database service we will create in our Docker environment. In this guide, we'll call our database service db. Go ahead and replace the listed value of DB_HOST with the database service name:
[label .env]
APP_NAME=Travellist
APP_ENV=dev
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
LOG_CHANNEL=stack
DB_CONNECTION=mysql
DB_HOST=<^>db<^>
DB_PORT=3306
DB_DATABASE=<^>travellist<^>
DB_USERNAME=<^>travellist_user<^>
DB_PASSWORD=<^>password<^>
...
Feel free to also change the database name, username, and password, if you wish. These variables will be leveraged in a later step where we'll set up the docker-compose.yml file to configure our services.
Save the file when you're done editing. If you used nano, you can do that by pressing Ctrl+x, then Y and Enter to confirm.
Step 3 — Setting Up the Application's Dockerfile
Although both our MySQL and Nginx services will be based on default images obtained from the Docker Hub, we still need to build a custom image for the application container. We'll create a new Dockerfile for that.
Our travellist image will be based on the php:7.4-fpm official PHP image from Docker Hub. On top of that basic PHP-FPM environment, we'll install a few extra PHP modules and the Composer dependency management tool.
We'll also create a new system user; this is necessary to execute artisan and composer commands while developing the application. The uid setting ensures that the user inside the container has the same uid as your system user on your host machine, where you're running Docker. This way, any files created by these commands are replicated in the host with the correct permissions. This also means that you'll be able to use your code editor of choice in the host machine to develop the application that is running inside containers.
Create a new Dockerfile with:
nano Dockerfile
Copy the following contents to your Dockerfile:
[label Dockerfile]
FROM php:7.4-fpm
# Arguments defined in docker-compose.yml
ARG user
ARG uid
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libonig-dev \
libxml2-dev \
zip \
unzip
# Clear cache
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
# Get latest Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Create system user to run Composer and Artisan Commands
RUN useradd -G www-data,root -u $uid -d /home/$user $user
RUN mkdir -p /home/$user/.composer && \
chown -R $user:$user /home/$user
# Set working directory
WORKDIR /var/www
USER $user
Don't forget to save the file when you're done.
Our Dockerfile starts by defining the base image we're using: php:7.4-fpm.
After installing system packages and PHP extensions, we install Composer by copying the composer executable from its latest official image to our own application image.
A new system user is then created and set up using the user and uid arguments that were declared at the beginning of the Dockerfile. These values will be injected by Docker Compose at build time.
Finally, we set the default working dir as /var/www and change to the newly created user. This will make sure you're connecting as a regular user, and that you're on the right directory, when running composer and artisan commands on the application container.
Step 4 — Setting Up Nginx Configuration and Database Dump Files
When creating development environments with Docker Compose, it is often necessary to share configuration or initialization files with service containers, in order to set up or bootstrap those services. This practice facilitates making changes to configuration files to fine-tune your environment while you're developing the application.
We'll now set up a folder with files that will be used to configure and initialize our service containers.
To set up Nginx, we'll share a travellist.conf file that will configure how the application is served. Create the docker-compose/nginx folder with:
mkdir -p docker-compose/nginx
Open a new file named travellist.conf within that directory:
nano docker-compose/nginx/travellist.conf
Copy the following Nginx configuration to that file:
[label docker-compose/nginx/travellist.conf]
server {
listen 80;
index index.php index.html;
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
root /var/www/public;
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.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;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
gzip_static on;
}
}
This file will configure Nginx to listen on port 80 and use index.php as default index page. It will set the document root to /var/www/public, and then configure Nginx to use the app service on port 9000 to process *.php files.
Save and close the file when you're done editing.
To set up the MySQL database, we'll share a database dump that will be imported when the container is initialized. This is a feature provided by the MySQL 8.0 image we'll be using on that container.
Create a new folder for your MySQL initialization files inside the docker-compose folder:
mkdir docker-compose/mysql
Open a new .sql file:
nano docker-compose/mysql/init_db.sql
The following MySQL dump is based on the database we've set up in our Laravel on LEMP guide. It will create a new table named places. Then, it will populate the table with a set of sample places.
Add the following code to the file:
[label docker-compose/mysql/init_db.sql]
DROP TABLE IF EXISTS `places`;
CREATE TABLE `places` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`visited` tinyint(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `places` (name, visited) VALUES ('Berlin',0),('Budapest',0),('Cincinnati',1),('Denver',0),('Helsinki',0),('Lisbon',0),('Moscow',1),('Nairobi',0),('Oslo',1),('Rio',0),('Tokyo',0);
The places table contains three fields: id, name, and visited. The visited field is a flag used to identify the places that are still *to go*. Feel free to change the sample places or include new ones. Save and close the file when you're done.
We've finished setting up the application's Dockerfile and the service configuration files. Next, we'll set up Docker Compose to use these files when creating our services.
Step 5 — Creating a Multi-Container Environment with Docker Compose
Docker Compose enables you to create multi-container environments for applications running on Docker. It uses *service definitions* to build fully customizable environments with multiple containers that can share networks and data volumes. This allows for a seamless integration between application components.
To set up our service definitions, we'll create a new file called docker-compose.yml. Typically, this file is located at the root of the application folder, and it defines your containerized environment, including the base images you will use to build your containers, and how your services will interact.
We'll define three different services in our docker-compose.yml file: app, db, and nginx.
The app service will build an image called travellist, based on the Dockerfile we've previously created. The container defined by this service will run a php-fpm server to parse PHP code and send the results back to the nginx service, which will be running on a separate container. The mysql service defines a container running a MySQL 8.0 server. Our services will share a bridge network named travellist.
The application files will be synchronized on both the app and the nginx services via bind mounts. Bind mounts are useful in development environments because they allow for a performant two-way sync between host machine and containers.
Create a new docker-compose.yml file at the root of the application folder:
nano docker-compose.yml
A typical docker-compose.yml file starts with a version definition, followed by a services node, under which all services are defined. Shared networks are usually defined at the bottom of that file.
To get started, copy this boilerplate code into your docker-compose.yml file:
[label docker-compose.yml]
version: "3.9"
services:
networks:
<^>travellist<^>:
driver: bridge
We'll now edit the services node to include the app, db and nginx services.
The app Service
The app service will set up a container named travellist-app. It builds a new Docker image based on a Dockerfile located in the same path as the docker-compose.yml file. The new image will be saved locally under the name travellist.
Even though the document root being served as the application is located in the nginx container, we need the application files somewhere inside the app container as well, so we're able to execute command line tasks with the Laravel Artisan tool.
Copy the following service definition under your services node, inside the docker-compose.yml file:
[label docker-compose.yml]
app:
build:
args:
user: <^>sammy<^>
uid: <^>1000<^>
context: ./
dockerfile: Dockerfile
image: <^>travellist<^>
container_name: <^>travellist-app<^>
restart: unless-stopped
working_dir: /var/www/
volumes:
- <^>./:/var/www<^>
networks:
- <^>travellist<^>
These settings do the following:
build: This configuration tells Docker Compose to build a local image for theappservice, using the specified path (context) and Dockerfile for instructions. The argumentsuseranduidare injected into the Dockerfile to customize user creation commands at build time.image: The name that will be used for the image being built.container_name: Sets up the container name for this service.restart: Always restart, unless the service is stopped.working_dir: Sets the default directory for this service as/var/www.volumes: Creates a shared volume that will synchronize contents from the current directory to/var/wwwinside the container. Notice that this is not your document root, since that will live in thenginxcontainer.networks: Sets up this service to use a network namedtravellist.
The db Service
The db service uses a pre-built MySQL 8.0 image from Docker Hub. Because Docker Compose automatically loads .env variable files located in the same directory as the docker-compose.yml file, we can obtain our database settings from the Laravel .env file we created in a previous step.
Include the following service definition in your services node, right after the app service:
[label docker-compose.yml]
db:
image: <^>mysql:8.0<^>
container_name: <^>travellist-db<^>
restart: unless-stopped
environment:
MYSQL_DATABASE: <^>${DB_DATABASE}<^>
MYSQL_ROOT_PASSWORD: <^>${DB_PASSWORD}<^>
MYSQL_PASSWORD: <^>${DB_PASSWORD}<^>
MYSQL_USER: <^>${DB_USERNAME}<^>
SERVICE_TAGS: dev
SERVICE_NAME: mysql
volumes:
- <^>./docker-compose/mysql:/docker-entrypoint-initdb.d<^>
networks:
- travellist
These settings do the following:
image: Defines the Docker image that should be used for this container. In this case, we're using a MySQL 8.0 image from Docker Hub.container_name: Sets up the container name for this service:travellist-db.restart: Always restart this service, unless it is explicitly stopped.environment: Defines environment variables in the new container. We're using values obtained from the Laravel.envfile to set up our MySQL service, which will automatically create a new database and user based on the provided environment variables.volumes: Creates a volume to share a.sqldatabase dump that will be used to initialize the application database. The MySQL image will automatically import.sqlfiles placed in the/docker-entrypoint-initdb.ddirectory inside the container.networks: Sets up this service to use a network namedtravellist.
The nginx Service
The nginx service uses a pre-built Nginx image on top of Alpine, a lightweight Linux distribution. It creates a container named travellist-nginx, and it uses the ports definition to create a redirection from port 8000 on the host system to port 80 inside the container.
Include the following service definition in your services node, right after the db service:
[label docker-compose.yml]
nginx:
image: <^>nginx:1.17-alpine<^>
container_name: <^>travellist-nginx<^>
restart: unless-stopped
ports:
- <^>8000:80<^>
volumes:
- <^>./:/var/www<^>
- <^>./docker-compose/nginx:/etc/nginx/conf.d<^>
networks:
- travellist
These settings do the following:
image: Defines the Docker image that should be used for this container. In this case, we're using the Alpine Nginx 1.17 image.container_name: Sets up the container name for this service: travellist-nginx.restart: Always restart this service, unless it is explicitly stopped.ports: Sets up a port redirection that will allow external access via port8000to the web server running on port80inside the container.volumes: Creates two shared volumes. The first one will synchronize contents from the current directory to/var/wwwinside the container. This way, when you make local changes to the application files, they will be quickly reflected in the application being served by Nginx inside the container. The second volume will make sure our Nginx configuration file, located atdocker-compose/nginx/travellist.conf, is copied to the container's Nginx configuration folder.networks: Sets up this service to use a network namedtravellist.
Finished docker-compose.yml File
This is how our finished docker-compose.yml file looks like:
[label docker-compose.yml]
version: "3.9"
services:
<^>app<^>:
build:
args:
user: sammy
uid: 1000
context: ./
dockerfile: Dockerfile
image: travellist
container_name: <^>travellist-app<^>
restart: unless-stopped
working_dir: /var/www/
volumes:
- ./:/var/www
networks:
- travellist
<^>db<^>:
image: mysql:8.0
container_name: <^>travellist-db<^>
restart: unless-stopped
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_USER: ${DB_USERNAME}
SERVICE_TAGS: dev
SERVICE_NAME: mysql
volumes:
- ./docker-compose/mysql:/docker-entrypoint-initdb.d
networks:
- travellist
<^>nginx<^>:
image: nginx:1.17-alpine
container_name: <^>travellist-nginx<^>
restart: unless-stopped
ports:
- 8000:80
volumes:
- ./:/var/www
- ./docker-compose/nginx:/etc/nginx/conf.d/
networks:
- travellist
networks:
travellist:
driver: bridge
Make sure you save the file when you're done.
Step 6 — Running the Application with Docker Compose
We'll now use docker compose commands to build the application image and run the services we specified in our setup.
Build the app image with the following command:
docker compose build app
This command might take a few minutes to complete. You'll see output similar to this:
[secondary_label Output]
Building app
Sending build context to Docker daemon 377.3kB
Step 1/11 : FROM php:7.4-fpm
---> 8c08d993542f
Step 2/11 : ARG user
---> e3ce3af04d87
Step 3/11 : ARG uid
---> 30cb921ef7df
Step 4/11 : RUN apt-get update && apt-get install -y git curl libpng-dev libonig-dev libxml2-dev zip unzip
. . .
---> b6dbc7a02e95
Step 5/11 : RUN apt-get clean && rm -rf /var/lib/apt/lists/*
---> 10ef9dde45ad
. . .
Step 6/11 : RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
. . .
---> 920e4f09ec75
Step 7/11 : COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
---> dbbcd44e44af
Step 8/11 : RUN useradd -G www-data,root -u $uid -d /home/$user $user
---> db98e899a69a
Step 9/11 : RUN mkdir -p /home/$user/.composer && chown -R $user:$user /home/$user
---> 5119e26ebfea
Step 10/11 : WORKDIR /var/www
---> 699c491611c0
Step 11/11 : USER $user
---> cf250fe8f1af
Successfully built cf250fe8f1af
Successfully tagged travellist:latest
When the build is finished, you can run the environment in background mode with:
docker compose up -d
[secondary_label Output]
Creating travellist-db ... done
Creating travellist-app ... done
Creating travellist-nginx ... done
This will run your containers in the background. To show information about the state of your active services, run:
docker compose ps
You'll see output like this:
[secondary_label Output]
Name Command State Ports
-----------------------------------------------------------------------------------------------
travellist-app docker-php-entrypoint php-fpm Up 9000/tcp
travellist-db docker-entrypoint.sh mysqld Up 3306/tcp, 33060/tcp
travellist-nginx nginx -g daemon off; Up 0.0.0.0:8000->80/tcp,:::8000->80/tcp
Your environment is now up and running.
You can now verify that each containerized service is responding correctly using simple curl commands.
- To test the Nginx web service:
curl -I http://localhost:8000
Expected output:
HTTP/1.1 200 OK
Server: nginx/1.17.10
Content-Type: text/html; charset=UTF-8
This confirms that the Nginx container is running and serving responses from your Laravel application.
- To test PHP-FPM through Nginx:
curl -s http://localhost:8000 | grep "Berlin"
If the application is running correctly, this command returns the welcome page HTML that includes the word *Berlin*.
We still need to execute a couple commands to finish setting up the application. You can use the docker compose exec command to execute commands in the service containers, such as an ls -l to show detailed information about files in the application directory:
docker compose exec <^>app<^> <^>ls -l<^>
[secondary_label Output]
total 256
-rw-r--r-- 1 sammy sammy 737 Apr 18 14:21 Dockerfile
-rw-r--r-- 1 sammy sammy 101 Jan 7 2020 README.md
drwxr-xr-x 6 sammy sammy 4096 Jan 7 2020 app
-rwxr-xr-x 1 sammy sammy 1686 Jan 7 2020 artisan
drwxr-xr-x 3 sammy sammy 4096 Jan 7 2020 bootstrap
-rw-r--r-- 1 sammy sammy 1501 Jan 7 2020 composer.json
-rw-r--r-- 1 sammy sammy 179071 Jan 7 2020 composer.lock
drwxr-xr-x 2 sammy sammy 4096 Jan 7 2020 config
drwxr-xr-x 5 sammy sammy 4096 Jan 7 2020 database
drwxr-xr-x 4 sammy sammy 4096 Apr 18 14:22 docker-compose
-rw-r--r-- 1 sammy sammy 1017 Apr 18 14:29 docker-compose.yml
-rw-r--r-- 1 sammy sammy 1013 Jan 7 2020 package.json
-rw-r--r-- 1 sammy sammy 1405 Jan 7 2020 phpunit.xml
drwxr-xr-x 2 sammy sammy 4096 Jan 7 2020 public
-rw-r--r-- 1 sammy sammy 273 Jan 7 2020 readme.md
drwxr-xr-x 6 sammy sammy 4096 Jan 7 2020 resources
drwxr-xr-x 2 sammy sammy 4096 Jan 7 2020 routes
-rw-r--r-- 1 sammy sammy 563 Jan 7 2020 server.php
drwxr-xr-x 5 sammy sammy 4096 Jan 7 2020 storage
drwxr-xr-x 4 sammy sammy 4096 Jan 7 2020 tests
-rw-r--r-- 1 sammy sammy 538 Jan 7 2020 webpack.mix.js
We'll now run composer install to install the application dependencies:
docker compose exec app <^>rm -rf vendor composer.lock<^>
docker compose exec app <^>composer install<^>
You'll see output like this:
[secondary_label Output]
No composer.lock file present. Updating dependencies to latest instead of installing from lock file. See https://getcomposer.org/install for more information.
. . .
Lock file operations: 89 installs, 0 updates, 0 removals
- Locking doctrine/inflector (2.0.4)
- Locking doctrine/instantiator (1.4.1)
- Locking doctrine/lexer (1.2.3)
- Locking dragonmantank/cron-expression (v2.3.1)
- Locking egulias/email-validator (2.1.25)
- Locking facade/flare-client-php (1.9.1)
- Locking facade/ignition (1.18.1)
- Locking facade/ignition-contracts (1.0.2)
- Locking fideloper/proxy (4.4.1)
- Locking filp/whoops (2.14.5)
. . .
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 89 installs, 0 updates, 0 removals
- Downloading doctrine/inflector (2.0.4)
- Downloading doctrine/lexer (1.2.3)
- Downloading dragonmantank/cron-expression (v2.3.1)
- Downloading symfony/polyfill-php80 (v1.25.0)
- Downloading symfony/polyfill-php72 (v1.25.0)
- Downloading symfony/polyfill-mbstring (v1.25.0)
- Downloading symfony/var-dumper (v4.4.39)
- Downloading symfony/deprecation-contracts (v2.5.1)
. . .
Generating optimized autoload files
> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover --ansi
Discovered Package: facade/ignition
Discovered Package: fideloper/proxy
Discovered Package: laravel/tinker
Discovered Package: nesbot/carbon
Discovered Package: nunomaduro/collision
Package manifest generated successfully.
The last thing we need to do before testing the application is to generate a unique application key with the artisan Laravel command-line tool. This key is used to encrypt user sessions and other sensitive data:
docker compose exec app <^>php artisan key:generate<^>
[secondary_label Output]
Application key set successfully.
Now go to your browser and access your server's domain name or IP address on port 8000:
http://server_domain_or_IP:8000
Note: In case you are running this demo on your local machine, use http://localhost:8000 to access the application from your browser.
You'll see a page like this:
You can also test the Laravel application from inside the app container without using a browser:
docker compose exec app curl http://nginx
If everything is configured correctly, you should see the HTML output of Laravel's default welcome page.
<html>
<head>
<title>Travel List</title>
</head>
<body>
<h1>My Travel Bucket List</h1>
<h2>Places I'd Like to Visit</h2>
<ul>
<li>Berlin</li>
<li>Budapest</li>
<li>Denver</li>
<li>Helsinki</li>
<li>Lisbon</li>
<li>Nairobi</li>
<li>Rio</li>
<li>Tokyo</li>
</ul>
<h2>Places I've Already Been To</h2>
<ul>
<li>Cincinnati</li>
<li>Moscow</li>
<li>Oslo</li>
</ul>
</body>
</html>
You can use the logs command to check the logs generated by your services:
docker compose logs <^>nginx<^>
Attaching to <^>travellist-nginx<^>
. . .
travellist-nginx | 172.24.9.1 - - [18/Apr/2022:14:49:16 +0000] "GET / HTTP/1.1" 200 627 "-" "curl/7.82.0"
travellist-nginx | 172.24.9.1 - - [18/Apr/2022:14:51:27 +0000] "GET / HTTP/1.1" 200 627 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0"
travellist-nginx | 172.24.9.1 - - [18/Apr/2022:14:51:27 +0000] "GET /favicon.ico HTTP/1.1" 200 0 "http://localhost:8000/" "Mozilla/5.0 (X11; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0"
You can also use the following curl command to trigger log entries:
curl http://localhost:8000
Then re-run:
docker compose logs nginx
You should now see a matching access log entry for your request.
If you want to pause your Docker Compose environment while keeping the state of all its services, run:
docker compose pause
[secondary_label Output]
Pausing travellist-db ... done
Pausing travellist-nginx ... done
Pausing travellist-app ... done
You can then resume your services with:
docker compose unpause
[secondary_label Output]
Unpausing travellist-app ... done
Unpausing travellist-nginx ... done
Unpausing travellist-db ... done
To shut down your Docker Compose environment and remove all of its containers, networks, and volumes, run:
docker compose down
[secondary_label Output]
Stopping travellist-nginx ... done
Stopping travellist-db ... done
Stopping travellist-app ... done
Removing travellist-nginx ... done
Removing travellist-db ... done
Removing travellist-app ... done
Removing network travellist-laravel-demo_travellist
For an overview of all Docker Compose commands, please check the Docker Compose command-line reference.
Fixing Laravel File and Directory Permissions
When running Laravel inside Docker containers, one of the most common issues developers face is related to file and directory permissions. Laravel requires certain directories to be writable by the PHP-FPM process inside the app container. If these directories are not writable, the application may fail to log errors, cache configuration files, or store session data correctly.
You might encounter messages such as:
The stream or file "/var/www/storage/logs/laravel.log" could not be opened in append mode: failed to open stream: Permission denied
or
file_put_contents(/var/www/bootstrap/cache/config.php): Failed to open stream: Permission denied
These errors indicate that the user running PHP within the container does not have permission to write to the necessary directories.
The following steps describe how to diagnose and fix these issues in both local and production environments.
Adjust Writable Directory Permissions
Laravel requires two directories to be writable by the web server user:
/var/www/storage/var/www/bootstrap/cache
If you see permission errors, you can fix them by adjusting ownership and permissions from inside the container:
docker compose exec app chown -R sammy:sammy /var/www/storage /var/www/bootstrap/cache
docker compose exec app chmod -R 775 /var/www/storage /var/www/bootstrap/cache
If your Dockerfile uses a different username (defined through the user build argument), replace sammy with that username.
You can verify the ownership with the following command:
docker compose exec app ls -ld /var/www/storage /var/www/bootstrap/cache
The output should show that the user and group match the PHP-FPM process inside the container.
If you want to adjust permissions from the host machine instead, run:
sudo chown -R $(id -u):$(id -g) storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache
This ensures that both Laravel directories are writable by the container and your host user.
Set Directory Ownership During Image Build
To prevent permission issues from recurring after every rebuild, you can set directory ownership during the Docker image build process. Open your Dockerfile and add the following line near the end, before switching to the non-root user:
RUN chown -R $user:www-data /var/www
This command ensures that all files and directories under /var/www are owned by the user you defined in your Dockerfile.
For a more targeted and secure approach, you can adjust only the directories Laravel needs to write to:
RUN chown -R $user:www-data /var/www/storage /var/www/bootstrap/cache
After making this change, rebuild the image and restart the containers:
docker compose build app
docker compose up -d
The next time the container starts, it will already have the correct permissions.
Verify User ID Consistency Between Host and Container
When using bind mounts, Docker shares files between your host and container. If the host user's numeric UID differs from the UID defined in the container, file ownership conflicts may occur. This is common on systems where user IDs vary between machines.
To verify the UID values, run:
id -u $(whoami)
docker compose exec app id -u
If these two numbers do not match, update your docker-compose.yml file to pass your actual UID to the build process:
build:
args:
user: sammy
uid: 1000
Then rebuild the image:
docker compose build app
docker compose up -d
This ensures that the container user matches your host user, preventing permission mismatches on bind-mounted files.
Fix Composer Cache Directory Permissions
Composer stores downloaded dependencies and cache files in the user's home directory inside the container (/home/sammy/.composer). If this directory is not writable, you may see warnings like:
Cannot create cache directory /home/sammy/.composer/cache
You can fix this by creating the cache directory and assigning the correct ownership:
docker compose exec app mkdir -p /home/sammy/.composer/cache
docker compose exec app chown -R sammy:sammy /home/sammy/.composer
This ensures that Composer can store dependencies and cache files without permission errors, which also speeds up future composer install runs.
Recommended Practices for Production Environments
In production, permission management should be handled during the image build rather than at runtime. This improves consistency and security.
Follow these practices for a production-ready configuration:
- Avoid bind mounts for application code: Instead, copy the application files into the image during build. Bind mounts should be reserved for development.
- Set permissions during build: Add the
RUN chownandchmodcommands to your Dockerfile to ensure the correct ownership and access rights before deployment.
- Use the least-privileged user: Ensure the PHP process runs under a non-root user (the current setup with
$useralready does this).
- Restrict permissions on non-writable directories: Most application files should be read-only. You can enforce this with:
chmod -R 755 /var/www
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
This setup allows Laravel to write only where necessary while protecting other application files from modification.
By following these steps, your Laravel containers will have stable, predictable file permissions in both local and production environments. This reduces recurring errors and ensures Laravel can log data, cache files, and write sessions reliably.
Understanding docker-compose.override.yml for Local and Production Environments
Docker Compose allows you to define multi-container applications using a single configuration file. In most cases, this configuration is stored in a file named docker-compose.yml. However, managing multiple environments such as local development and production, often requires environment-specific adjustments. This is where the docker-compose.override.yml file becomes useful.
By default, Docker Compose automatically looks for a file named docker-compose.override.yml in the same directory as the base Compose file. When found, it merges the configurations from both files when you run any docker compose command. This approach allows developers to maintain a clean, reusable base configuration while customizing specific aspects of the setup for local or production use.
The Role of docker-compose.override.yml
The docker-compose.override.yml file is an extension of the base Compose configuration. It modifies or adds properties that apply only to a particular environment, without requiring changes to the primary docker-compose.yml file. The override file is especially useful in local development workflows, where developers often need to mount source code, enable debugging tools, or modify environment variables for testing.
When you run the docker compose up command, Docker automatically merges the contents of docker-compose.yml and docker-compose.override.yml. If a service, volume, or configuration is defined in both files, the values in the override file take precedence.
To use a different override file, for example, one intended for production, you can specify the file manually using the -f flag:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
This approach allows you to maintain separate configurations for different environments while using a single consistent command structure.
Structuring for Local Development
Local development environments typically require frequent changes, live code reloading, and verbose logging. The docker-compose.override.yml file makes it possible to introduce these features without affecting other environments.
A typical local override file may include:
- Bind mounts for live code updates
- Local build directives instead of using prebuilt images
- Development-specific environment variables
- Debugging and testing services
For example:
[label docker-compose.override.yml]
version: "3.9"
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- ./src:/app
- ./logs:/app/logs
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/app_db
- LOG_LEVEL=debug
ports:
- "8000:8000"
depends_on:
- db
db:
environment:
- POSTGRES_PASSWORD=postgres
In this example:
- The
appservice builds directly from a local Dockerfile (Dockerfile.dev), making it easy to test changes without rebuilding images through a registry. - Source code is mounted using bind mounts, allowing the container to reflect local file changes in real time.
- Environment variables such as
LOG_LEVEL=debugare set to provide more detailed output during development. - The database uses simple credentials that are suitable for local testing but should not be used in production.
This configuration provides a faster and more flexible development environment while keeping the base configuration unchanged.
Preparing for Production
Production environments require stability, reproducibility, and security. Containers should run from prebuilt images rather than being built locally, and sensitive information should be managed securely. Unlike the automatically applied override file used for local development, production overrides must be specified explicitly.
A production override file (docker-compose.prod.yml) is typically designed to replace local build steps with image references, use named volumes instead of bind mounts, and define deployment-related properties such as resource limits and scaling.
Example:
[label docker-compose.prod.yml]
version: "3.9"
services:
app:
image: registry.example.com/myapp:1.2.3
environment:
- DATABASE_URL=${DATABASE_URL}
- LOG_LEVEL=info
volumes:
- app-data:/var/lib/app
ports:
- "8000:8000"
restart: unless-stopped
deploy:
replicas: 3
resources:
limits:
cpus: "0.5"
memory: 512M
db:
image: postgres:15
volumes:
- db-data:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
secrets:
db_password:
external: true
volumes:
app-data:
db-data:
In this configuration:
- The
appservice references a prebuilt image (myapp:1.2.3) stored in a registry instead of building locally. - Bind mounts are replaced with named volumes to ensure consistent and isolated data storage.
- Sensitive credentials are stored securely using Docker secrets.
- The
deploysection defines scaling behavior and resource limits for better stability under load. - The configuration must be applied explicitly during deployment:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
This structure ensures that production deployments are consistent, controlled, and free from local development artifacts.
Comparing Local and Production Overrides
| Configuration Aspect | Local (docker-compose.override.yml) |
Production (docker-compose.prod.yml) |
|---|---|---|
| Applied automatically | Yes | No (must use -f) |
| Source code handling | Bind mounts for live updates | Prebuilt images from registry |
| Environment variables | Developer-focused values | Production-safe values |
| Logging | Verbose (debug) |
Concise (info or error) |
| Secrets management | Plain values or .env |
Docker secrets or managed secrets |
| Scaling | Single container | Multiple replicas |
| Performance tuning | Not required | Resource limits defined |
| Debugging tools | Enabled | Disabled |
| Restart policy | Manual or none | restart: unless-stopped |
Using multiple Docker Compose files provides a clean way to separate environment-specific configurations from the base setup. The base docker-compose.yml defines the common application structure, while docker-compose.override.yml adjusts it for local development. When deploying to production, an explicit override file ensures a predictable and secure configuration.
This pattern allows teams to maintain a consistent workflow. Developers can start the application locally using:
docker compose up
and deploy to production using:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Both environments share the same core configuration while maintaining independent settings suitable for their specific requirements. This separation improves maintainability, reduces configuration errors, and keeps local and production environments aligned.
Troubleshooting Your Environment
This section provides troubleshooting steps for common problems you may encounter when running your app with Docker Compose.
502 Bad Gateway (Nginx shows 502 responses)
Symptoms:
- Browser shows 502 Bad Gateway when accessing
http://localhost:8000. - Nginx logs contain lines like
recv() failed (111: Connection refused)orconnect() failed (111: Connection refused) while connecting to upstream.
Likely causes:
- PHP-FPM (the
appservice) is not running or not listening on the expected socket/port. - Nginx
fastcgi_passpoints to the wrong host/port. - Container name mismatch or network issues between
nginxandapp. appcrashed on startup (e.g., due to missing extensions or configuration errors).
How to confirm:
- Inspect Nginx logs:
docker compose logs nginx | tail -n 100
Look for 502 messages or upstream connection errors.
- Check
appcontainer status and logs:
docker compose ps app
docker compose logs app --tail=200
- Confirm PHP-FPM is listening inside the
appcontainer:
docker compose exec app ss -ltnp | grep :9000 || docker compose exec app ss -lxp | grep php-fpm
Expected: either a TCP listener on port 9000 or a Unix socket used by nginx (less common with this compose).
- Confirm
nginxcan resolve and reachappon the compose network:
docker compose exec nginx ping -c 2 app
docker compose exec nginx wget -qO- --timeout=2 http://app:9000/ || true
If ping fails or wget times out, there is a networking or service name mismatch.
Common fixes:
- If PHP-FPM is not running, inspect
applogs for fatal PHP errors or startup failures. Fix the underlying error (often missing extension or permission problem) and restart:
docker compose restart app
docker compose logs app --tail=100
- If Nginx configuration uses a different upstream (e.g.,
fastcgi_pass 127.0.0.1:9000;), updatedocker-compose/nginx/travellist.conftofastcgi_pass app:9000;(matching your compose service name) and reload Nginx:
# After editing config in host
docker compose exec nginx nginx -s reload
docker compose logs nginx --tail=50
- If PHP-FPM listens on a Unix socket (e.g.,
/run/php/php7.4-fpm.sock) but Nginx expects TCP or vice versa, standardize to TCPapp:9000for Compose setups or mount the socket correctly between containers (recommended: use TCP in multi-container Compose).
Missing PHP extensions (errors when running Composer or Artisan, or runtime failures)
Symptoms:
- Composer or Laravel commands fail with messages like
Class "PDO" not found,ext-pdo is missing, orCall to undefined function json_encode. - PHP errors in
applogs referencing undefined classes or functions.
How to confirm:
- Inspect the
appcontainer PHP module list:
docker compose exec app php -m | sort
Search for the extension names you need (for Laravel typical extensions: pdo_mysql, mbstring, bcmath, openssl, tokenizer, xml, ctype, json, gd).
- Check composer error text which usually names required extensions:
docker compose exec app composer install
Fixes:
- Add missing extensions in your Dockerfile and rebuild the image. Example (for
pdo_mysqlandgd):
RUN apt-get update && apt-get install -y libpng-dev libxml2-dev \
&& docker-php-ext-install pdo_mysql mbstring xml gd
Then rebuild and restart:
docker compose build app
docker compose up -d app
docker compose logs app --tail=100
- If an extension requires system libraries (for example
gdneedslibpng-dev), add the correspondingapt-get installlines beforedocker-php-ext-install.
- If you need a PHP extension that is not built-in, consider installing via PECL and enable it in
php.ini.
Database connection failures (Laravel cannot connect to MySQL)
Symptoms:
- Laravel shows database connection errors (
SQLSTATE[HY000] [2002] Connection refusedorAccess denied for user). - Migrations fail, or the application shows an empty page for data-driven pages.
How to confirm:
- Check DB container logs for initialization errors:
docker compose logs db --tail=200
- Verify DB container is up:
docker compose ps db
- Confirm credentials and host are consistent between
.envanddocker-compose.yml. In the Laravel.env,DB_HOSTshould be the compose service name (db), not127.0.0.1orlocalhost:
DB_HOST=db
DB_PORT=3306
DB_DATABASE=travellist
DB_USERNAME=travellist_user
DB_PASSWORD=password
- Test from inside the
dbcontainer:
docker compose exec db mysql -u"${DB_USERNAME}" -p"${DB_PASSWORD}" -e "SHOW DATABASES;"
Or, test from the app container to ensure network connectivity:
docker compose exec app sh -c 'which mysql >/dev/null 2>&1 || echo "no mysql client"; mysql -h db -u"${DB_USERNAME}" -p"${DB_PASSWORD}" -e "SELECT VERSION();"'
Common causes and fixes:
- Wrong host in
.env: updateDB_HOST=db, recreate containers or reload envs:
docker compose down
docker compose up -d
docker compose exec app php artisan migrate
- DB not ready when app starts: use a retry mechanism in your app or add a healthcheck and start ordering. You can also wait manually:
docker compose exec db mysqladmin -u"${DB_USERNAME}" -p"${DB_PASSWORD}" ping
# expected: mysqld is alive
- Initialization SQL failing: check files under
./docker-compose/mysql, a syntax or permission error can abort DB init. Inspect DB logs for import errors. - Privileges / wrong credentials: confirm the database and user exist. Recreate the DB or run SQL to grant privileges.
- Host-level port usage: if you attempted
curl telnet://localhost:3306and thedbservice does not expose a host port, that connection will be refused by design. Test from inside containers, or expose the port indocker-compose.yml(ports: - "3306:3306") for local-only debugging.
Permission and filesystem issues (storage, cache, log files)
Symptoms:
- Laravel throws exceptions related to writing cache or logs:
The stream or file "/var/www/storage/logs/laravel.log" could not be opened. - Files created by container processes are owned by
rootand are not writable by your host user.
How to confirm:
- Check file permissions from the host:
ls -l
ls -l storage bootstrap/cache
docker compose exec app id -u # user id inside container
- Inspect container logs for permission errors.
Fixes:
- Ensure the Dockerfile sets the user and UID that matches your host user (your Dockerfile already creates a user with
ARG uid). Confirm theuidindocker-compose.ymlbuild args matches your host UID (usually1000). - Fix ownership from host or inside container:
docker compose exec app chown -R sammy:sammy /var/www/storage /var/www/bootstrap/cache
or from the host:
sudo chown -R $(id -u):$(id -g) storage bootstrap/cache
- For development, bind mounts can cause permission mismatches; prefer to run container commands to adjust ownership after bringing containers up.
Composer or vendor-related errors
Symptoms:
composer installfails insideappor missingvendorfolder cause class not found.composerreports memory exhaustion in container.
How to confirm:
- Run composer inside the
appcontainer and capture output:
docker compose exec app composer install --no-interaction --prefer-dist
Fixes:
- If
composermemory limits are reached, allow more memory or set composer environment variable:
docker compose exec app php -d memory_limit=-1 /usr/bin/composer install
- Ensure Composer executable is present (your Dockerfile copies composer). If not, rebuild image with the composer layer.
- If
vendoris missing due to bind mount overlay, ensure your bind mount is not hiding thevendorfolder created in the image. Best practice is to runcomposer installafter containers are up sovendoris created in the mounted folder, or addvendorto the container via image build for production.
Application returns blank page or 500 errors
Symptoms:
- Browser shows blank response or HTTP 500.
- Nginx logs show 200 but page empty;
applogs show fatal exceptions.
How to confirm:
- Inspect application logs:
docker compose exec app tail -n 200 storage/logs/laravel.log
docker compose logs app --tail=200
- Check environment:
APP_DEBUG=truein.envwill make Laravel show detailed errors in development (do not enable in production).
Fixes:
- Fix the underlying exception shown in
laravel.log. - Ensure
.envexists andAPP_KEYis set:
docker compose exec app php artisan key:generate
- Run migrations, cache clear:
docker compose exec app php artisan migrate --force
docker compose exec app php artisan config:cache
docker compose exec app php artisan route:cache
docker compose exec app php artisan view:cache
Container build failures (errors during docker compose build)
Symptoms:
docker compose builderrors out with apt or build step failures.
How to confirm:
- Re-run build with logs:
docker compose build app
Fixes:
- Read the failing step output. Typical issues:
- Network/DNS in the build environment: retry or ensure Docker can access the internet.
- Missing packages or wrong apt source: add
apt-get updatebefore installs and clean apt lists afterwards. - Permissions on files copied into the image: ensure correct
COPYpaths.
Example Dockerfile snippet for robust installs:
RUN apt-get update && apt-get install -y --no-install-recommends \
libpng-dev libxml2-dev zip unzip \
&& docker-php-ext-install pdo_mysql mbstring xml gd \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
Network or DNS problems between containers
Symptoms:
- One container cannot reach another by service name (e.g.,
appcannot reachdbwithdb:3306). pingby service name fails.
How to confirm:
docker compose exec app ping -c 3 db
docker compose exec app getent hosts db
docker network inspect travellist
Fixes:
- Confirm services share the same network in
docker-compose.yml(both should referencetravellist). - If you changed service names or network names, update
DB_HOSTand Nginx upstreams accordingly. - Recreate network (this destroys ephemeral containers, so be careful):
docker compose down
docker network prune # optional and destructive
docker compose up -d
Useful diagnostic commands
Run these for targeted inspection:
- Show running containers and ports:
docker compose ps
- Tail logs for a service:
docker compose logs -f --tail=200 nginx
- Execute a shell inside a container:
docker compose exec app sh
docker compose exec db bash
- Inspect container env variables:
docker compose exec app env | sort
docker compose exec db env | grep MYSQL
- Check container process list:
docker compose exec app ps aux
docker compose exec app ss -ltnp
- Check health of MySQL:
docker compose exec db mysqladmin -u"${DB_USERNAME}" -p"${DB_PASSWORD}" ping
When to expose host ports for debugging
By default the db service in this setup is not bound to host ports. Expose ports only for local debugging. To temporarily expose MySQL on the host add to docker-compose.yml under db:
ports:
- "3306:3306"
Then restart:
docker compose up -d db
After debugging, remove the ports mapping to avoid exposing the DB to the network.
FAQs
1. Do I need Composer installed on the host?
No. You can run Composer entirely inside the app container, which is the recommended approach for this Compose workflow. The Dockerfile in the tutorial includes Composer, so run commands like docker compose exec app composer install or docker compose run --rm app composer require vendor/package. Running Composer in the container ensures the right PHP version and extensions are used and avoids producing vendor files with mismatched ownership on the host.
2. Can I run Laravel migrations from inside the container?
Yes. Run migrations from the app container so they use the same runtime and network as your application: docker compose exec app php artisan migrate. For one-off or CI tasks you can use docker compose run --rm app php artisan migrate --force. Before running migrations make sure the database container is healthy and accepting connections, for example with docker compose exec db mysqladmin -u${DB_USERNAME} -p${DB_PASSWORD} ping or by retrying until it responds.
3. How do I install additional PHP extensions?
Add the necessary system packages and extension commands to the Dockerfile, then rebuild the image. For example, install dependencies and build common extensions with something like:
RUN apt-get update && apt-get install -y libpng-dev libjpeg-dev \
&& docker-php-ext-configure gd --with-jpeg \
&& docker-php-ext-install pdo_mysql mbstring gd
If you need a PECL extension use pecl install and docker-php-ext-enable. After editing the Dockerfile run docker compose build app and then docker compose up -d app to apply the changes.
4. How do I expose the Nginx container for HTTPS?
Add an HTTPS server block to your Nginx config, mount your certificate and key into the container, and publish port 443 in docker-compose. Here's an example with minimal changes in docker-compose.yml:
services:
nginx:
ports:
- "8000:80"
- "443:443"
volumes:
- ./docker-compose/nginx:/etc/nginx/conf.d
- ./certs:/etc/nginx/certs:ro
Then add an SSL server block that references /etc/nginx/certs/fullchain.pem and /etc/nginx/certs/privkey.pem. For production, use a reverse proxy or certificate manager such as Traefik or an external load balancer to obtain and renew certificates securely instead of storing private keys in the app stack.
5. How do I rebuild Laravel after code changes?
It depends on what changed. If you are using bind mounts for the project directory, changes to PHP, templates, routes and controllers are reflected immediately; run Laravel cache clears if needed, for example:
docker compose exec app php artisan config:clear
docker compose exec app php artisan route:clear
docker compose exec app php artisan view:clear
If you changed composer dependencies or the Dockerfile (new extensions or system packages), reinstall or rebuild: docker compose exec app composer install for dependency changes, and docker compose build app && docker compose up -d for Dockerfile changes. Restart the affected service when you change PHP-FPM or Nginx configuration: docker compose restart app or docker compose restart nginx.
Conclusion
In this guide, we've set up a Docker environment with three containers using Docker Compose to define our infrastructure in a YAML file.
From this point on, you can work on your Laravel application without needing to install and set up a local web server for development and testing. Moreover, you'll be working with a disposable environment that can be easily replicated and distributed, which can be helpful while developing your application and also when moving towards a production environment.
For more Laravel tutorials, check out the following articles: