Home » Laravel » Laravel Development with Docker Tutorial

Laravel Development with Docker Tutorial

When I first started web development, I installed PHP, Apache, and MySQL server directly on a Windows computer, and later on Linux. At that time, builds like XAMPP were also popular, as they already contained all the necessary tools and even had a simple management interface. But I didn’t like to use such builds, because they required using only the services that the author had set up, and it was not so easy to choose versions or install something additional you needed. And then Docker started to gain popularity. It is a containerization system that allows you to create and run containers with different Linux distributions and software based on a configuration file.

It is very convenient for quickly deploying an application on different hardware. You can prepare everything locally and then run it with a few commands on any server where Docker is installed. But Docker is convenient not only for project deployment but also for local development. On the one hand, you can configure the installation of all the necessary services of the required versions and make the necessary settings. On the other hand, when everything is set up, it is very easy to transfer it to another operating system or reconfigure it with different parameters.

Laravel already has a package called laravel/sail that allows you to create a Docker configuration for your project. But I prefer to configure the necessary containers myself. In this article, I will show you how to create a Docker Compose configuration for developing a Laravel application.

All commands in this article were executed on Ubuntu Linux, but they should most likely work on other Linux distributions, as well as in WSL on Windows.


Table of Contents

What Containers do We Need?

The Docker philosophy is to have only one main process running in each container. I will try to stick to this as much as possible. We will create a container for each service, as well as a main container from which artisan commands can be executed and which will handle queues and cron commands. So we will need the following containers:

  • Nginx – a web server to which the browser will make requests, it will return static resources such as images and CSS files, and also redirect PHP requests to a container with PHP-FPM.
  • PHP-FPM – PHP interpreter that works in PHP-FPM mode.
  • PostgreSQL – database.
  • Redis – In-memory database for storing information about queues and cache;
  • Main – container with PHP-CLI and Supervisor in which processes will be executed that process queued jobs and scheduled tasks.

How to Develop a Laravel Application in Docker

I assume that Docker is already installed on your system. If not, you can check out the instructions on how to install it on the official website. In this article, I’ll use the following directory structure:

project |--src |--docker |--|--php-fpm |--|--|--conf.d |--|--nginx |--|--|--conf.d |--|--main |--|--|--supervisor |--docker-compose.yaml |--.env

As you can see, the application code is separated from the Docker environment configuration files. First, let’s create a project folder and navigate to it:

mkdir project cd project

1. Creating docker-compose.yaml

Now we need to create the docker compose configuration file. In this section, we’ll fill in only the first part of the file, which specifies the version of the configuration standard, as well as the Docker volumes that will be needed for the services. Volumes are virtual storages that are mounted in a container. When you re-build the container, all changes made are deleted, so if you need to keep certain files, you need to mount either a Docker volume or a local folder in the folder where these files will be stored. In our project, we need at least one volume for the PostgreSQL database files. The code will look like this:

docker-compose.yamlversion: "3.9" volumes: db-data: services:

In this case, the volume will be called db-data. By default, all containers described in the docker compose configuration file are networked and accessible by name. But if you need to run multiple Docker Compose projects, or if you have a VPN installed, you may experience IP address conflicts. To avoid this, you can explicitly add the networks section and specify which IP address range can be used. For example:

docker-compose.yamlnetworks: project-network: driver: bridge ipam: config: - subnet: 172.16.57.0/24

2. Creating an Environment File

Some services require you to store sensitive data, or data that may change depending on the environment. For example, a password for a database. It is better not to put such data directly in docker-compose.yaml, because this file can be sent to a public repository and should definitely not contain any passwords. Docker Compose supports inserting data from the .env file. Therefore, you can create a file with environment variables and place all the necessary data there. For example, the name of the database, its password, and the ID of the current user/group on the system.

.envDB_PASSWORD=12345 DB_NAME=laravel USER_ID=1000 GROUP_ID=1000

The ID of the current user and group on Linux can be found with the following command:

id

I’ll explain what the last two variables are for later.

3. Creating a PostgreSQL Container

Let’s start with simpler containers and then move on to more complex ones. Configuring a container for PostgreSQL looks pretty simple. We’ll use the official image, mount the db-data volume, and make port 5432 available to the host. This may be necessary to connect to the database using DBeaver, DataGrip, or some other client:

docker-compose.yaml postgresql: restart: unless-stopped image: postgres user: postgres ports: - "5432:5432" environment: - POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_DB=${DB_NAME:-laravel} volumes: - db-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready", "-d", "{$DB_NAME}"] interval: 2s timeout: 30s retries: 10 start_period: 40s

Here, the database name and password are read from the .env file using the ${variable_name:-default_value} syntax. The db-data volume described earlier is mounted in the /var/lib/postgresql/data folder. And the restart option with the value unless-stopped allows you to keep the container running until you manually stop it, even after restarting the computer. The last section healthcheck describes a command that checks if the service in the container is really ready to process incoming connections. This is necessary in order to run PHP containers only after the database is fully launched.

4. Creating a Redis Container

The Redis container configuration also looks pretty simple:

docker-compose.yaml redis: restart: unless-stopped image: redis ports: - "6379:6379"

Here we only expose the port, no additional settings are required.

5. Creating a PHP-FPM Container

The configuration of this container will be a bit more complicated. The docker-compose parameters and the official PHP image will not be enough. For Laravel to work correctly, you will need to install several additional PHP extensions. It will also be necessary to make sure that the user ID in the container on behalf of which PHP-FPM is running matches the user ID of your host system in order to avoid problems with permissions.

There are two ways to install PHP extensions in the official PHP image. You can use the docker-php-ext-install script and the pecl script. Both scripts come by default, and if an extension is not supported by the former, it can always be installed from PEAR using pecl. Please note that it will not work to install extensions from the repositories of the distribution on which the container is built, because the container is designed to build everything from the program code, and you simply cannot activate extensions installed from the repositories.

Let’s create a Dockerfile based on the official image and make the necessary changes. For local development, you can use Debian. This is how the file will look like:

docker/php-fpm/DockerfileFROM php:8.1-fpm-bullseye ARG USER_ID=1000; ARG GROUP_ID=1000; #mbstring - libonig-dev #pdo_pgsql - libpq-dev #zip - libzip-dev #intl - libicu-dev RUN apt update && apt install -y libonig-dev libpq-dev libzip-dev libicu-dev #Install required extensions RUN docker-php-ext-install mbstring pdo pdo_pgsql pgsql zip intl pcntl RUN pecl install redis \ && docker-php-ext-enable redis #Set timezone ENV TZ=Europe/Kyiv RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone #Change UID and GID for www-data to local user UID/GID RUN sed -i "s|www-data:x:33:33|www-data:x:${USER_ID}:${GROUP_ID}|g" /etc/passwd RUN sed -i "s|www-data:x:33:|www-data:x:${GROUP_ID}:|g" /etc/group WORKDIR /var/www

If you want to make any changes to the standard PHP configuration, it is not in /etc/ as you might think, but in /usr/local/etc/. This is done because PHP in the container is compiled from source code. For example, in order to add your own PHP configuration files, you should create a folder conf.d, put all the necessary ini files in it, and then copy them to the folder /usr/local/etc/php/conf.d:

docker/php-fpm/DockerfileCOPY conf.d/ /usr/local/etc/php/conf.d/

By default, the path to the folder starts from the folder where the container context and Dockerfile are located, so you do not need to specify the full path.

And this is how the section in docker-compose.yaml will look like:

docker-compose.yaml php-fpm: restart: unless-stopped build: context: ./docker/php-fpm args: - USER_ID=${USER_ID:-1000} - GROUP_ID=${GROUP_ID:-1000} volumes: - ./src:/var/www depends_on: - postgresql

Here we specify the context on the basis of which you want to build the container instead of the image, passing there the user and group identifier you specified in the .env file, and also mounts the folder with the project files in /var/www, which will allow you to edit them in the IDE and immediately have all the changes in the container. The depends_on parameter indicates that this container should be run only after PostgreSQL is running. And since we previously specified the healthcheck function for that container, Docker will also check that PostgreSQL is ready to process queries.

Note that in the Dockerfile we change the user ID www-data and its group using the sed command from 33 to the identifiers that were specified in the .env file. The fact is that Linux identifies users by their ID, so in the container, the project files will belong to the user www-data, and in the host system to your user. This avoids problems with permissions. This is not very critical for the PHP-FPM container, but we will do the same in the main container, and then if you run artisan commands that create files in the container, then these files can be edited in the IDE without changing permissions.

6. Creating an Nginx container

The configuration of the Nginx container is also quite simple. By default, Nginx imports all the configuration files that are in the /etc/nginx/conf.d/ directory. Therefore, we do not need to make a Dockerfile, we can simply mount a local folder with the required configuration in this folder. This is what the Nginx configuration file will look like:

docker/nginx/conf.d/application.conf server { listen 80; server_name application.local !default; root /var/www/public/; client_max_body_size 64M; index index.html index.htm index.php; charset utf-8; location / { try_files $uri $uri/ /index.php?$query_string; } error_page 404 /index.php; location ~ \.php$ { fastcgi_pass php-fpm:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; } }

Pay attention to the line fastcgi_pass php-fpm:9000; Here php-fpm is the hostname associated with the php-fpm container. Docker makes containers available on the network that unites all the containers of one project, so there is nothing else to configure here. The 9000 port is also available on the internal network between containers without additional configuration. Everything else in this configuration file is almost the same as suggested in the Laravel documentation.

And this is how the section in docker-compose.yaml:

docker-compose.yaml nginx: restart: unless-stopped image: nginx ports: - "8080:80" volumes: - ./src:/var/www - ./docker/nginx/conf.d/:/etc/nginx/conf.d/ depends_on: - php-fpm

In addition to mounting the configuration files, here we make the web server available to the host machine on port 8080.

7. Creating the Main Container

This container will have the most complex configuration. Here we will need about the same Dockerfile as for PHP-FPM, but additionally we need to install and configure the Supervisor process manager, and Composer. It is also worth preparing the Docker configuration so that the application can deploy itself after an update without additional actions on the part of the developer, which means that at least two commands need to be executed before starting the supervisor process: composer install and artisan migrate. Therefore, it is very important that this container is launched after the database is completely ready. Let’s start with supervisor. You need a configuration file for the queues in which the execution of artisan queue:work will be configured, and another file for artisan schedule:work. You just need to put them in the /etc/supervisor/conf.d/ folder in the container. Here is the contents of these files:

docker/main/supervisor/queue.conf[program:queue-worker] process_name=%(program_name)s_%(process_num)02d command= php artisan queue:work --timeout=0 directory = /var/www/ autostart=true autorestart=true numprocs=1 user=www-data stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0

docker/main/supervisor/scheduler.conf[program:schedule-worker] process_name=%(program_name)s_%(process_num)02d command= php artisan schedule:work directory = /var/www/ autostart=true autorestart=true numprocs=1 user=www-data stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0

Here you have configured to run only one instance of each process using numprocs. You can add more by simply increasing the number. Further, since I want the container to run basic initialization commands when it is reloaded, and then Supervisor will be launched. In order to place these commands conveniently, we will create the file entrypoint.sh:

docker/main/entrypoint.sh#!/bin/bash # Install dependencies composer install --no-interaction # Run migrations php artisan migrate --force # Run main process sudo supervisord -n

In the Dockerfile we additionally install Composer, and also configure the execution of entrypoint.sh. An important point is that Supervisor must be run as root, and the main user of the container must be www-data in order for you to execute artisan commands. Therefore, you should install sudo and allow the www-data user to use it:

docker/main/DockerfileFROM php:8.1-cli-bullseye ARG USER_ID=1000; ARG GROUP_ID=1000; #mbstring - libonig-dev #pdo_pgsql - libpq-dev #zip - libzip-dev #intl - libicu-dev RUN apt update && apt install -y libonig-dev libpq-dev libzip-dev libicu-dev supervisor sudo RUN apt install -y libpng-dev #Install required extensions RUN docker-php-ext-install mbstring pdo pdo_pgsql pgsql zip intl pcntl RUN pecl install redis \ && docker-php-ext-enable redis RUN docker-php-ext-install exif gd #Set timezone ENV TZ=Europe/Kyiv RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone #Install composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer RUN composer config -g process-timeout 3600 && \ composer config -g repos.packagist composer https://packagist.org COPY supervisor/ /etc/supervisor/conf.d/ COPY entrypoint.sh /entrypoint.sh RUN chmod a+x /entrypoint.sh #Change UID and GID for www-data to local user UID/GID RUN sed -i "s|www-data:x:33:33|www-data:x:${USER_ID}:${GROUP_ID}|g" /etc/passwd RUN sed -i "s|www-data:x:33:|www-data:x:${GROUP_ID}:|g" /etc/group WORKDIR /var/www #Allow user www-data to use sudo RUN echo "www-data ALL = (ALL) NOPASSWD: ALL" | tee /etc/sudoers.d/www-data USER www-data ENTRYPOINT ["/entrypoint.sh"]

Now we just need to add the section to the docker-compose.yaml:

docker-compose.yaml main: restart: unless-stopped build: context: ./docker/main args: - USER_ID=${USER_ID:-1000} - GROUP_ID=${GROUP_ID:-1000} volumes: - ./src:/var/www init: true depends_on: postgresql: condition: service_healthy

The init option here is needed to start an init process in the container that will receive signals from Docker and forward them to the supervisor process. This is necessary, in particular, so that the process is quickly terminated when you run docker compose in the terminal and then press Ctrl+C.

8. Deploying an Application

To make sure everything works, let’s deploy the Koel project written in Laravel in this container. First, download the project files from GitHub on the Releases page and unpack the contents into the ./src folder. The unpacked files should look like this:

Koel has an installation command that we’ll run later. But if you want to deploy your project, you need to create a file .env for Laravel in the ./src/ folder. A standard Laravel installation has a .env.example file that you can use as a basis. I will show only the lines that you need to pay attention to regarding this docker configuration:

src/.envAPP_URL=http://localhost:8080 DB_CONNECTION=pgsql DB_HOST=postgresql DB_PORT=5432 DB_DATABASE=laravel DB_USERNAME=postgres DB_PASSWORD=12345 REDIS_HOST=redis

As the hostname, we use the service name for PostgreSQL and Redis. You will also need to generate an encryption key:

php src/artisan key:generate

After that, you can run the docker compose command to build the containers:

sudo docker compose up --build

Or run it in the background:

sudo docker compose up --build -d

After adding new packages or migrations, they will be applied when entrypoint.sh is executed each time the container is started. Next, you need to connect to the container with the following command:

sudo docker compose exec main bash

And here you can execute all the necessary artisan commands. For example, you can execute the Koel install command:

php artisan koel:init --no-assets

After that, you can open the http://localhost:8080 address in your browser and enter the project’s web interface:

Full Code of docker-compose.yaml

Here is the full code of docker-compose.yaml:

docker-compose.yamlversion: "3.9" volumes: db-data: networks: project-network: driver: bridge ipam: config: - subnet: 172.16.57.0/24 services: postgresql: restart: unless-stopped image: postgres user: postgres ports: - "5432:5432" environment: - POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_DB=${DB_NAME:-laravel} volumes: - db-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready", "-d", "{$DB_NAME}"] interval: 2s timeout: 30s retries: 10 start_period: 40s redis: restart: unless-stopped image: redis ports: - "6379:6379" php-fpm: restart: unless-stopped build: context: ./docker/php-fpm args: - USER_ID=${USER_ID:-1000} - GROUP_ID=${GROUP_ID:-1000} volumes: - ./src:/var/www depends_on: - postgresql nginx: restart: unless-stopped image: nginx ports: - "8080:80" volumes: - ./src:/var/www - ./docker/nginx/conf.d/:/etc/nginx/conf.d/ depends_on: - php-fpm main: restart: unless-stopped build: context: ./docker/main args: - USER_ID=${USER_ID:-1000} - GROUP_ID=${GROUP_ID:-1000} volumes: - ./src:/var/www depends_on: postgresql: condition: service_healthy

Most Common Issues

One of the Containers is Not Starting

It may happen, that one of the containers you are building based on a Dockerfile won’t run because you have corrupted some configuration files. The main problem here is that you can’t connect to a container that isn’t running to run the necessary commands and find out what’s wrong with it. In order not to guess what went wrong, you can specify an infinite Bash loop as the main container process in the Dockerfile. For example, for the main container:

docker/main/Dockerfile#ENTRYPOINT ["/entrypoint.sh"] CMD while true; do echo "Sleeping..."; sleep 2; done

After that, the container will start and you can connect to it and figure out what’s wrong with it.

Address Already in Use

This error can be received if some port that one of the containers wants to use is already in use on the host system. In this configuration, we have made ports 8080, 6379, and 5432 available to the host. If you get this error, you need to make sure that these ports are free, for example, using the ss command. For PosgreSQL, the command will look like this:

sudo ss -tulpn | grep 5432

The ss command shows not only that the port is in use, but also the name and ID of the process that uses it, if you run it as root. Then you just need to terminate that process. For example, to stop PostgreSQL on a host, run:

sudo systemctl stop postgresql

Similarly for other ports.

Permission Denied

Since the user ID of www-data and your user in the system is the same, there should be no problems with default permissions. For example, all files that belong to the haait user will automatically belong to the www-data user in the coterie. But an error may appear if you accidentally execute a composer or artisan command as root or another user. Then it is enough to return the rights to your user on the host system for all files in the ./src/ folder. For example:

chown haait:haait -R ./src/

If it still doesn’t work, you can give all rights for all users to the folder ./src folder using the following command:

chmod 777 -R ./src/

Wrapping Up

In this article, I have shown how I use Docker for Laravel development on a local machine. This configuration can also be used to deploy a project anywhere. However, deploying to a server may require a little different configuration and definitely won’t require replacing the user ID, which is more relevant for development. You can also place multiple projects in the ./src folder by simply creating a separate folder and Nginx configuration file for each project.

2 thoughts on “Laravel Development with Docker Tutorial”

    • The docker-php-fpm container is used in Nginx container to handle PHP requests from users while the main-app handles queues, scheduler and PHP in the command line. Here:


      location ~ \.php$ {
      fastcgi_pass docker-php-fpm:9000;
      fastcgi_index index.php;
      fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
      include fastcgi_params;
      }

      Reply

Leave a Comment