By Erika Heidi, Jamon Camisso and Manikandan Kurup

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:
app service running PHP7.4-FPM;db service running MySQL 8.0;nginx service that uses the app service to parse PHP code before serving the Laravel application to the final user.To allow for a streamlined development process and facilitate application debugging, we’ll keep application files in sync by using shared volumes. We’ll also see how to use docker compose exec commands to run Composer and Artisan on the app container.
Deploy your frontend applications from GitHub using DigitalOcean App Platform. Let DigitalOcean focus on scaling your app.
Key Takeaways:
.env file allows developers to change database credentials and other settings without touching application code.docker-compose.override.yml enables local tweaks—like live reloading—without altering production configurations. This keeps development and deployment cleanly separated.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.
.env FileThe 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: 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:
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.
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:
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.
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:
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:
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.
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:
version: "3.9"
services:
networks:
travellist:
driver: bridge
We’ll now edit the services node to include the app, db and nginx services.
app ServiceThe 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:
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 the app service, using the specified path (context) and Dockerfile for instructions. The arguments user and uid are 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/www inside the container. Notice that this is not your document root, since that will live in the nginx container.networks: Sets up this service to use a network named travellist.db ServiceThe 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:
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 .env file 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 .sql database dump that will be used to initialize the application database. The MySQL image will automatically import .sql files placed in the /docker-entrypoint-initdb.d directory inside the container.networks: Sets up this service to use a network named travellist.nginx ServiceThe 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:
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 port 8000 to the web server running on port 80 inside the container.volumes: Creates two shared volumes. The first one will synchronize contents from the current directory to /var/www inside 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 at docker-compose/nginx/travellist.conf, is copied to the container’s Nginx configuration folder.networks: Sets up this service to use a network named travellist.docker-compose.yml FileThis is how our finished docker-compose.yml file looks like:
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.
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:
OutputBuilding 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
OutputCreating 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:
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
Outputtotal 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:
OutputNo 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
OutputApplication 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
OutputPausing travellist-db ... done
Pausing travellist-nginx ... done
Pausing travellist-app ... done
You can then resume your services with:
- docker compose unpause
OutputUnpausing 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
OutputStopping 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.
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.
Laravel requires two directories to be writable by the web server user:
/var/www/storage/var/www/bootstrap/cacheIf 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.
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.
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.
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.
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 chown and chmod commands 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 $user already 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.
docker-compose.override.yml for Local and Production EnvironmentsDocker 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.
docker-compose.override.ymlThe 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.
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:
For example:
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:
app service builds directly from a local Dockerfile (Dockerfile.dev), making it easy to test changes without rebuilding images through a registry.LOG_LEVEL=debug are set to provide more detailed output during development.This configuration provides a faster and more flexible development environment while keeping the base configuration unchanged.
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:
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 app service 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 deploy section 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.
| 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.
This section provides troubleshooting steps for common problems you may encounter when running your app with Docker Compose.
Symptoms:
http://localhost:8000.recv() failed (111: Connection refused) or connect() failed (111: Connection refused) while connecting to upstream.Likely causes:
app service) is not running or not listening on the expected socket/port.fastcgi_pass points to the wrong host/port.nginx and app.app crashed 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 app container status and logs:
- docker compose ps app
- docker compose logs app --tail=200
Confirm PHP-FPM is listening inside the app container:
- 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 nginx can resolve and reach app on 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 app logs 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;), update docker-compose/nginx/travellist.conf to fastcgi_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 TCP app:9000 for Compose setups or mount the socket correctly between containers (recommended: use TCP in multi-container Compose).
Symptoms:
Class "PDO" not found, ext-pdo is missing, or Call to undefined function json_encode.app logs referencing undefined classes or functions.How to confirm:
Inspect the app container 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_mysql and gd):
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 gd needs libpng-dev), add the corresponding apt-get install lines before docker-php-ext-install.
If you need a PHP extension that is not built-in, consider installing via PECL and enable it in php.ini.
Symptoms:
SQLSTATE[HY000] [2002] Connection refused or Access denied for user).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 .env and docker-compose.yml. In the Laravel .env, DB_HOST should be the compose service name (db), not 127.0.0.1 or localhost:
DB_HOST=db
DB_PORT=3306
DB_DATABASE=travellist
DB_USERNAME=travellist_user
DB_PASSWORD=password
Test from inside the db container:
- 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: update DB_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:3306 and the db service does not expose a host port, that connection will be refused by design. Test from inside containers, or expose the port in docker-compose.yml (ports: - "3306:3306") for local-only debugging.
Symptoms:
The stream or file "/var/www/storage/logs/laravel.log" could not be opened.root and 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 the uid in docker-compose.yml build args matches your host UID (usually 1000).
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.
Symptoms:
composer install fails inside app or missing vendor folder cause class not found.composer reports memory exhaustion in container.How to confirm:
Run composer inside the app container and capture output:
- docker compose exec app composer install --no-interaction --prefer-dist
Fixes:
If composer memory 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 vendor is missing due to bind mount overlay, ensure your bind mount is not hiding the vendor folder created in the image. Best practice is to run composer install after containers are up so vendor is created in the mounted folder, or add vendor to the container via image build for production.
Symptoms:
app logs 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=true in .env will make Laravel show detailed errors in development (do not enable in production).
Fixes:
Fix the underlying exception shown in laravel.log.
Ensure .env exists and APP_KEY is 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
docker compose build)Symptoms:
docker compose build errors 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:
apt-get update before installs and clean apt lists afterwards.COPY paths.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/*
Symptoms:
app cannot reach db with db:3306).ping by 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 reference travellist).
If you changed service names or network names, update DB_HOST and 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
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
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.
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.
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.
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.
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.
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.
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:
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
Dev/Ops passionate about open source, PHP, and Linux. Former Senior Technical Writer at DigitalOcean. Areas of expertise include LAMP Stack, Ubuntu, Debian 11, Linux, Ansible, and more.
With over 6 years of experience in tech publishing, Mani has edited and published more than 75 books covering a wide range of data science topics. Known for his strong attention to detail and technical knowledge, Mani specializes in creating clear, concise, and easy-to-understand content tailored for developers.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!
Great quality as usual from DO. You intend MySQL 5.7 but the configurations all refer to MySQL8.
What’s the point in creating a non-root user but adding it to the root group?
RUN useradd -G www-data,root -u $uid -d /home/$user $user
I tried the above but keep getting the following error
In JsonFile.php line 181:
file_put_contents(./composer.lock): failed to open stream: Permissio
n denied `
and the file permission is still root
Hi, thanks for putting this together. I have a Laravel 9 app, and I followed your tutorial to the letter however I keep getting SQLSTATE[HY000] [1045] Access denied for user ‘root’@‘172.18.0.4’ (using password: YES) been on this for weeks now, googled everywhere on the internet nothing worked. I have made some minor adjustments since then to the docker-compose and Dockerfile, based on my project requirements. I’d really appreciate pointers in the right direction, at this point I am fed up! Thank you.
docker-compose.yml
version: "3.7"
services:
app:
build: .
container_name: cookbookshq
depends_on:
- db
volumes:
- ./:/var/www
networks:
- cookbooks
db:
image: mysql:latest
container_name: db
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: false
MYSQL_DATABASE: test_db
MYSQL_PASSWORD: pass
volumes:
- cookbooks-db:/var/lib/mysql
networks:
- cookbooks
nginx:
image: nginx:alpine
container_name: web-server
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./:/var/www
- ./docker-compose/nginx:/etc/nginx/conf.d/
networks:
- cookbooks
volumes:
cookbooks-db:
networks:
cookbooks:
driver: bridge
Dockerfile
FROM php:8.0-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 \
cron \
unzip \
redis-tools
# 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
RUN mkdir -p /var/www
WORKDIR /var/www
COPY ./ /var/www/
# 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 1000 -d /home/dev dev
RUN mkdir -p /home/dev/.composer && \
chown -R dev:dev /home/dev
# Set working directory
WORKDIR /var/www
USER $user
RUN composer install --no-interaction
RUN echo "memory_limit=1024M" >> /usr/local/etc/php/conf.d/php.ini
RUN echo "allow_url_fopen=on" >> /usr/local/etc/php/conf.d/php.ini
I have an issue at step to install composer:
docker compose exec app rm -rf vendor composer.lock
ERROR: rm: cannot remove ‘composer.lock’: Permission denied
Any solution for this?
Hi, great tutorial. I am new to PHP and docker. There is a typo in
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);
It should be
INSERT INTO places (name, visited) VALUES (‘Berlin’,0),(‘Budapest’,0),(‘Cincinnati’,1),(‘Denver’,0),(‘Helsinki’,0),(‘Lisbon’,0),(‘Kyiv’,1),(‘Nairobi’,0),(‘Oslo’,1),(‘Rio’,0),(‘Tokyo’,0);
Best
Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.
Full documentation for every DigitalOcean product.
The Wave has everything you need to know about building a business, from raising funding to marketing your product.
Stay up to date by signing up for DigitalOcean’s Infrastructure as a Newsletter.
New accounts only. By submitting your email you agree to our Privacy Policy
Scale up as you grow — whether you're running one virtual machine or ten thousand.
Sign up and get $200 in credit for your first 60 days with DigitalOcean.*
*This promotional offer applies to new accounts only.