Using Docker to Containerize Laravel Apps for Development and Production
Tags: laravel, docker
Published: 2022-03-23 15:00:36

*This article was originally written by [Geshan Manandhar](https://www.honeybadger.io/blog/authors/geshanmanandhar/) on the [Honeybadger Developer Blog](https://www.honeybadger.io/blog/laravel-docker-php/).*

In the past 5 years, both Docker and Laravel have exploded in [popularity](https://trends.google.com/trends/explore?date=2016-03-05%202021-06-03&q=%2Fm%2F0jwy148,%2Fm%2F0wkcjgj). In this step-by-step tutorial, we will dive into how to dockerize an existing Laravel app to run it on local and then make it ready to run on a production environment. We will also deploy it to Google Cloud Run without getting into Kubernetes or any YAML configs. Let’s get started

## What are containers?

Containers simply put, are a way of packaging an application in a way that in addition to the application code and its dependencies the whole stack including the language, file system, and operating system can be shipped together. Another added bonus of this form of packaging is the specific version of the language and operating system can be specified in each build.

There are multiple ways to define what [containers](https://cloud.google.com/containers) and [containerization](https://www.ibm.com/cloud/learn/containerization) are and how they [operate](https://www.netapp.com/devops-solutions/what-are-containers/#usecases). But without going to the details of virtualization and hypervisors the above way to understand them is simpler.

![Shipping containers analogy](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/01containers.jpg)

Photo by [Ian Taylor](https://unsplash.com/@carrier_lost) on [Unsplash](https://unsplash.com/)

If you want to refer to the shipping containers [analogy](https://gsferreira.com/archive/2018/03/exploring-the-docker-container-metaphor/), be my guest. The [benefits](https://www.ibm.com/cloud/learn/containerization#toc-benefits-1bZ7yTDN) of using containers include small size, speed, efficiency, and portability.

> To oversimplify things, you can think of containers as an improved virtual machine that is smaller, faster, and more resource-efficient.

## What is docker?

So if containers enable us to ship the whole stack on each deployment after a successful build of course, where does this Docker thing come into play? Docker is an open-source platform (and a company [Docker Inc](https://www.docker.com/company)) that enables software engineers to package applications into containers.

![Docker logo with whale and shipping containers](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/02docker-logo.png)

Therefore, Docker is a software that lets us build, package and run our applications as containers. But it is not the only option.

> Think of Docker as the AWS of the container world in terms of popularity, there is another container platform called rocket ([rkt](https://github.com/rkt/rkt/)) which can be considered something like Vultr in this analogy.

The [open container initiative](https://opencontainers.org/) looks at the standardization and governance of container runtimes.

Next up we will park these terms and theory here and jump into running some commands in the command line to meet our goal of creating development and production-ready containers for an existing Laravel application.

## Prerequisites

Before we dive into the code and docker commands, it would be great to make sure of the following things:

1. You have docker and docker-compose running on your machine
1. A general familiarity with how Laravel works will be needed
1. You are aware of how containers work and the need to build them and push them to a container registry.
1. To deploy the application you will need a Google cloud account.

Time to go deeper into dockerizing the Laravel app.

## Example application

For this post as we want to dockerize an existing Laravel application, we will use the [Student CRUD app](https://github.com/SinghDigamber/laravel-crud-app) built with Laravel by Digamber Rawat. The application is open source and he has a great [tutorial](https://www.positronx.io/php-laravel-crud-operations-mysql-tutorial/) explaining how the app was built.

It is a relatively simple Laravel 8 application using MySQL as the database. We will fork this application and dockerize it to run it on production not only on the dev environment. I would like to thank him for his amazing work on this application.

## Dockerize for local dev environment with Laravel sail

First, we will use [Laravel Sail]((https://laravel.com/docs/8.x/sail)) to run the application locally on our dev environment. There are unofficial Docker environments for Laravel like [Laradock](https://laradock.io/) but Sail is the official docker dev environment for Laravel. Sail is is a wrapper on top of [docker compose](https://docs.docker.com/compose/).

To `Sail-ize` our [existing](https://laravel.com/docs/8.x/sail#installing-sail-into-existing-applications) student CRUD app, we will run the following command after cloning the repository:

```bash
cd laravel-crud-app
cp .env.example .env
composer require laravel/sail --dev && php artisan sail:install
```

After the command ran, we should select 0 for MySQL to install MySQL as part of the docker-compose file created by Laravel sail as seen below:

![In Laravel sail prompt select 0 for MySQL](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/03laravel-sail-mysql.png)

At this point, make sure we have the `docker-compose.yml` file created at the root of the project. If the file is created, we can run the following command to build and run the needed containers:

```bash
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 ./vendor/bin/sail build
```

As Laravel Sail is a wrapper on top of Docker compose, what I have done here is instructed docker-compose to build the needed containers with [BuildKit](https://docs.docker.com/develop/develop-images/build_enhancements/).

Docker BuildKit is build enhancement available in the newer versions of Docker that makes the [docker build faster](https://geshan.com.np/blog/2020/10/docker-build-example-faster-docker-build/#easy-speed-up%2C-use-buildkit) and efficient. We will see the following output when the build process is done:

![Laravel sail built with docker buildkit enabled](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/04laravel-sail-buildkit.png)

Even with Docker BuildKit enabled the build process will take 5-10 minutes depending on the internet speed. To run the containers we will run the following command:

```bash
./vendor/bin/sail up
```

We will see an output towards the end of the command as follows:

![Laravel sail run project with sail up](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/05laravel-sail-up.png)

We will have to add the APP key in the .env running the following command after the container are running:

```bash
./vendor/bin/sail artisan key:generate
```

We will also need to change the database credentials in the `.env` file like below to make the app work.

```bash
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
```

By default, the MySQL container has a root user with no password, the above configuration will work. After that to create the database structure we will need to run the migrations with:

```bash
./vendor/bin/sail artisan migrate --force
```

Next, when we hit `http://localhost/students` we should see the empty list, we can go in and add a student to have an output that looks similar to below:

![Laravel project running with sail locally](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/06laravel-running-locally-with-sail.png)

Congrats! Our app is running locally with Laravel sail. Time to check production readiness for the Laravel sail container.

## Is Laravel sail’s docker image ready for production

A quick check on the image size with `docker images | grep sail` reveals that the docker image is 732 MB.

![Laravel sail docker image is very big at 732 MB](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/07laravel-sail-big.png)

It is not only the size on a deeper inspection of the Docker file at `./vendor/laravel/sail/runtimes/8.0/Dockerfile` it reveals that the supervisor file is using:

```bash
command=/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80 user=sail
```

That does not look like a production-ready web server, it is good for local development but running PHP as a production web server is [not a good idea](https://stackoverflow.com/questions/26257199/using-the-php-built-in-server-in-production).
The images is built on top of an Ubuntu image not the official PHP docker image. It also has [Node.js 15](https://github.com/laravel/sail/blob/1.x/runtimes/8.0/Dockerfile#L32) (not a LTS version of Node.js), yarn and [composer](https://github.com/laravel/sail/blob/1.x/runtimes/8.0/Dockerfile#L31) that will not be needed for a lean production image. It has been built with the development usecase in mind.

Therefore we will create a new Dockerfile and a new docker-compose file to test out a production-ready Docker image for Laravel. As the next step we will rename the current `docker-compose.yml` to `docker-compose-dev-sail.yml` with:

```bash
mv docker-compose.yml docker-compose-dev-sail.yml
```

Similarly, the sail up command will change to:

```bash
./vendor/bin/sail -f docker-compose-dev-sail.yml up
```

And if we hit the URL `http://localhost/students` again it should work as it was working earlier. All the changes done till now are in this [pull request](https://github.com/geshan/laravel-crud-app/pull/1/files) for your reference. Next up we will create a production-ready docker file and a docker-compose file to make it easy to test.

## Dockerize the application to be production-ready

To dockerize our Student CRUD app built on Laravel we will work on the following assumptions:

1. For a production environment we will be using a database as a service something like AWS RDS or Google Cloud SQL. For this demo, I will use a free [remote MySQL](https://remotemysql.com/) database.
1. We will only use official docker images to avoid any compatibility issues or security risks.
1. The official PHP Apache image will be used to keep the complexity low and avoid having multiple containers.
1. We will be using PHP 8.0 with opcache and Just In Time [JIT](https://stitcher.io/blog/php-8-jit-setup) for speed.
1. Docker multi-stage build and BuildKit will be used to make images smaller and build faster.
1. The database credentials are “open” for this demo in `.env.prod` file, it would be best to use environment variables to fill them up on runtime.

As the assumption are clear, we can jump to adding the docker file.

### Production friendly Dockerfile for Laravel app

We can start by adding a Dockerfile like below on the root of the project:

```dockerfile
FROM composer:2.0 as build
COPY . /app/
RUN composer install --prefer-dist --no-dev --optimize-autoloader --no-interaction

FROM php:8.0-apache-buster as production

ENV APP_ENV=production
ENV APP_DEBUG=false

RUN docker-php-ext-configure opcache --enable-opcache && \
    docker-php-ext-install pdo pdo_mysql
COPY docker/php/conf.d/opcache.ini /usr/local/etc/php/conf.d/opcache.ini

COPY --from=build /app /var/www/html
COPY docker/000-default.conf /etc/apache2/sites-available/000-default.conf
COPY .env.prod /var/www/html/.env

RUN php artisan config:cache && \
    php artisan route:cache && \
    chmod 777 -R /var/www/html/storage/ && \
    chown -R www-data:www-data /var/www/ && \
    a2enmod rewrite
```

Let’s have a look at how the image will be built using this [multi-stage](https://geshan.com.np/blog/2019/11/how-to-use-docker-multi-stage-build/) dockerfile.

First off, we use the official composer 2.0 image as the `build` stage. In this stage, we copy the whole application to `/app` of the container. Then we run composer install with parameters like `--no-dev` and `--optimize-autoloader` which are well suited for a production build. The production stage doesn't have composer as we don't need composer to run our application, we need it to install dependencies only.

Consequently, in the next stage named `production`, we start from the official PHP 8.0 apache image. After setting the two environment variables, `APP_ENV` to “production” and `APP_DEBUG` to false, we enable opcache with the following configuration placed at `./docker/php/conf.d/opcache.ini`:

```ini
[opcache]
opcache.enable=1
opcache.revalidate_freq=0
opcache.validate_timestamps=0
opcache.max_accelerated_files=10000
opcache.memory_consumption=192
opcache.max_wasted_percentage=10
opcache.interned_strings_buffer=16
opcache.jit_buffer_size=100M
```

[Opcache](https://wp-rocket.me/blog/what-is-opcache/) improves PHP performance by storing precompiled script bytecode in shared memory. This means the same PHP sprint does not need to be loaded and parsed on each request. As it is a cache, it will speed up the response but it will be a problem if used in development as the changes won’t reflect until the cache is refreshed.

Subsequently, we copy the whole app and its composer dependencies downloaded in the build stage to the `production` stage at `/var/www/html` the default document root for the official PHP Apache docker image. After that, we copy the Apache configuration from `./docker/000-default.conf` to `/etc/apache2/sites-available/000-default.conf` inside the container. The Apache configuration we copied in the container looks like below:

```apache
<VirtualHost *:80>

  ServerAdmin webmaster@localhost
  DocumentRoot /var/www/html/public/

  <Directory /var/www/>
    AllowOverride All
    Require all granted
  </Directory>

  ErrorLog ${APACHE_LOG_DIR}/error.log
  CustomLog ${APACHE_LOG_DIR}/access.log combined

</VirtualHost>
```

Next, we copy the `.env.prod` file that has the configs and credentials we need to run the application. We have used MySQL configuration from a database at Remote Mysql.

### Test out the Dockerfile with docker-compose

At this juncture, we can test our docker file. A way to do it is with a `docker build` command and pass lots of parameters. To make our lives easy, we will use the following docker-compose file and we can run `docker-compose build` or `docker-compose up` and not worry about remembering all the lengthy docker build parameters. Our `./docker-compose.yml` file looks like below:

```bash
version: '3'
services:
  app:
    build:
      context: ./
    volumes:
      - .:/var/www/html
    ports:
      - "80:80"
    environment:
      - APP_ENV=local
      - APP_DEBUG=true
```

Great! We have the docker-compose file ready too. We are using version 3 of the docker-compose definition. We have a single service called `app` which builds from the docker file at `./`. We are adding all the files from the current directory to `/var/www/html` this will sync the files. Next up we are exposing the docker port 80 to our local machine’s port 80. To debug things, we are setting up two environment variables `APP_ENV` as local and `APP_DEBUG` as true.

Before we build and run our docker container, let’s not forget about `.dockerignore`.

### Don't forget the Docker ignore

Same as `.gitigore` the `.dockerigore` is also a very handy docker feature. Similar to git ignore file a [docker ignore file](https://docs.docker.com/engine/reference/builder/#dockerignore-file) will ignore files from the local machine or build environment to be copied to the docker container when building the container. Our small docker ignore file looks like below:

```bash
.git
.env
```

Depending on your need you can add more things to the docker ignore file. I will leave that decision up to you on what should not land up in the docker image. People tend to ignore the Dockerfile and docker-compose.yml too, it is a choice.

File changes in this [pull request](https://github.com/geshan/laravel-crud-app/pull/2/files).

### Build and run on prod mode locally

As we have all the needed files in place, we can build our production-ready Laravel docker container with the following command:

```bash
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose build
```

It will give us an output that looks something like below after some minutes depending on your internet speed:

![Build the producion ready Laravel docker image having PHP and Apache with docker-compose](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/08docker-compose-build.png)

As the next step, we can check how big is the image that we just created with `docker images | grep app`. In my case it was 457 MB compared to 732 MB for the sail one:

![Production ready Laravel docker image is much smaller at 457 MB](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/09smaller-image.png)

Of course, the image is not very small at 457 MB. Still, it is much smaller than the sail one. If your concern is the image size, it would be great to explore using alpine base images with FPM and serve the application with Nginx. For the scope of this tutorial, we will not venture into that path that has two containers.

As the container is built, we can run it with the following command:

```bash
docker-compose up
```

It will give us the following output:

![Test the production Laravel docker image locally](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/10prod-image-running-locally.png)

One important thing to note here is the database migrations have already been run on the database we are working on. In case they were not run, we would need to run the following so that the tables are created:

```bash
docker-compose exec app php artisan migrate --force
```

This will run the database migration inside the container. It can also be executed with a simple docker run.

We can choose from multiple ways to run this migration. As this is an idempotent action, we can run it as part of the start script of the container, but it would be better to put it as part of the deployment process with `docker run`. If you plan to deploy this app on Kubernetes, the migration can be set up as an [init container](https://kubernetes.io/docs/concepts/workloads/pods/init-containers/) too.

## Deploy it on Google Cloud Run

Creating a production-ready container and running it only locally would not be much interesting for us. So we will deploy our Dockerized Laravel Student CRUD app on [Google Cloud Run](https://cloud.google.com/run). Cloud Run is a service to deploy containers in a serverless way. Yes, we can deploy containers that can scale up to [1000 instances](https://cloud.google.com/run/quotas) without hearing the words Kubernetes and Pods. Below is a quick way to run our app on production:

1. Make sure you are logged into your Google Cloud Account. If you don’t have one, get one with [$300 credit for 90 days](https://cloud.google.com/free/) at $0.
2. Go to [https://github.com/geshan/laravel-crud-app](https://github.com/geshan/laravel-crud-app)
3. Click on the “Run on Google Cloud” - that big blue button
4. It will open up Google Cloud Shell
5. Check the `trust` checkbox and then click confirm - image 09:54
6. It might take a bit for the cloud shell to warm up, it will ask us to authorize the gcloud command, click `Authorize`
7. Then we will need to select the Project of Google cloud
8. After that we will need to select the region, I generally go with `us-central-1`

The process will look like below till now:

![Deploying the Laravel app with docker container using deploy on Google Cloud run button](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/11cloud-run-button.gif)

- It will take some time for the container to build and push it to the [Google Container Registry](https://cloud.google.com/container-registry) (GCR)
- Consequently, it will deploy the container for us on Google Cloud Run using gcloud CLI.
- After the whole process is done, it will print the URL of our new cloud run service in green which ends in `a.run.app`. We can click it and see our app running on Cloud Run.

The process for the last three steps will look like below:

![Laravel with production ready docker deployed on Google Cloud run](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/12deployed-on-cloud-run.gif)

Then if we hit the service URL in green with `/students` added to it we will see our Laravel App running on Google Cloud Run like below:

![Laravel with production ready docker running on Google Cloud run](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/13app-on-google-cloud-run.png)

Congrats! There we have it, a Laravel app dockerized and then running on Google Cloud Run. It is time for you to explore Google Cloud Run a bit more.

![Explore Google Cloud Run a bit more](https://www.honeybadger.io/images/blog/posts/laravel-docker-php/14google-cloud-run.png)

Cloud Run is very simple and efficient in managing serverless containers.

> Autoscaling, redundancy, security, HTTPS URL, and custom domains are some of the amazing [features](https://cloud.google.com/run#all-features) of Cloud Run you can surely leverage upon.

If you are interested in how it can be deployed so easily on cloud run, please do have a look at this [pull request](https://github.com/geshan/laravel-crud-app/pull/3) which uses the deploy to [Cloud Run button](https://github.com/GoogleCloudPlatform/cloud-run-button).

## Other things to consider

It is great that your containerized Laravel app is deployed on a Google cloud service without writing a single line of YAML configuration. Since this is a demo tutorial we can surely make it better. One of the things that can be much better is surely secret management with proper environment variables for better security.

Other things that can be better for performance are using [Gzip compression](https://ubiq.co/tech-blog/enable-gzip-compression-apache/) and HTTP [caching headers](https://www.cloudsavvyit.com/3796/how-to-configure-cache-control-headers-in-apache/) on Apache. Again these are things that will be for you to explore further.

## Conclusion

We have seen how to dockerize a Laravel application for local development with Laravel sail. Then we re-dockerized the same application to be much more production-oriented. Finally, we deployed the app on super scalable and [feature-rich Google Cloud Run](https://geshan.com.np/blog/2019/11/why-use-google-cloud-run-5-compelling-reasons/) which is surely ready for prime time. If Cloud run can handle [IKEA’s workload](https://www.gcppodcast.com/post/episode-236-ikea-retail-ingka-group-with-matt-lawson/) it can surely handle yours, kudos to serverless containers.