All Articles

Dockerizing a Laravel Octane application

· Written by Vincent Bean

Deploying a Laravel application can be difficult for those who don't have experience with PHP applications. Docker is the solution to easily publish and let other people deploy your application without them needing to learn how it is exactly setup. In this article I will show how I've Dockerized Vigilant.

Introduction

The traditional setup process can be error-prone, requiring configuration in multiple applications (nginx, php, mysql, redis). This is not good if you want other people, who may not be familiar with PHP applications, to host your app. For example, what if you add a dependency in a later version of your application? With a traditional setup, everyone upgrading must remember to install that dependency.

Docker can simplify this process by packaging your entire application with all its dependencies into a single image. Think of it like a really small Linux instance that you get to configure. This way you are confident that it will run exactly the same on all machines.

Another advantage is scalability. If your application starts to struggle to keep up with all the HTTP requests coming in, you can spin up another container and load balance between the two. Or, if you need more queue workers, you can spin up a second container to do that."

You can extend that with Docker compose to add all the services you need such as MySQL and Redis.

Disclaimer: These are the steps I've taken to Dockerize Vigilant. Every application is different and may have its own unique dependencies. While not all steps are required for every application, others may need to add additional steps to address specific requirements. If you encounter any issues or have questions, feel free to join the Discord—I'd love to help!

Requirements

To Dockerize a Laravel application you need the following:

  • A working Laravel application

  • Docker with compose installed

The Application

The application we are dockerizing in this article is Vigilant. A monitoring tool for websites and webapplication that monitors more than just uptime.

It relies heavily on Laravel Horizon for all it's queues which is why we'll also be adding a seperate container just for running Horizon which we can easily scale if we need more processes that are handling our jobs.

Vigilant also runs Google Lighthouse which requires Chrome to be installed.

The Dockerfile

The Dockerfile contains the build process of the image. Here each step is defined to build the container.

Vigilant uses Laravel Octane with frankenphp, frankenphp provides a base image which we can use. To keep the image lightweight we are going to use the Alpine build. To get started create a new `Dockerfile` in the root of your Laravel project and start with a base image using the `FROM` command.

FROM dunglas/frankenphp:latest-php8.3-alpine

After that we'll add packages that we need. I always find these through trial and error; when building the application, you might encounter a dependency error. With experience one will start to recognize these errors and immediately know how to fix them. Use the Alpine packages site to find the package that you need.

# Dependencies
RUN apk add --no-cache bash git linux-headers libzip-dev libxml2-dev supervisor nodejs npm chromium

# Composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

# PHP extentions
RUN docker-php-ext-install pdo pdo_mysql sockets pcntl zip exif bcmath

# Redis
RUN apk --no-cache add pcre-dev ${PHPIZE_DEPS} \
      && pecl install redis \
      && docker-php-ext-enable redis \
      && apk del pcre-dev ${PHPIZE_DEPS} \
      && rm -rf /tmp/pear

At this point we have a base container but our application is missing. Using COPY we can copy the entire application into the image. After that we'll set the working directory of the container using WORKDIR.

COPY . /app
WORKDIR /app

We want to add all of our application's dependencies to the container, build our frontend assets, and add those to the container as well.

ENV COMPOSER_ALLOW_SUPERUSER=1
RUN composer install --no-dev --prefer-dist --no-interaction

RUN npm install
RUN npm run build

Vigilant has to be able to run Google Lighthouse, so we will install it globally in the container.

RUN npm install -g lighthouse
RUN npm install -g @lhci/[email protected]

We also need to get the Octane binary and we set the OCTANE_SERVER env variable:

RUN yes | php artisan octane:install --server=frankenphp
ENV OCTANE_SERVER=frankenphp

Finally, we set the location of the crontab, the CHROME_PATH environment variable for Lighthouse, and the entrypoint of the container.

RUN /usr/bin/crontab /app/docker/crontab

ENV CHROME_PATH=/usr/bin/chromium

ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"]

The entrypoint is the command that the container runs at startup. As you can see, we run a script here. The script looks like this:

#!/bin/sh

# Make sure all required Laravel dirs exist
mkdir -p /app/storage/framework/cache
mkdir -p /app/storage/framework/sessions
mkdir -p /app/storage/framework/views

# Generate a key if we do not have one
if ! grep -q "^APP_KEY=" ".env" || [ -z "$(grep "^APP_KEY=" ".env" | cut -d '=' -f2)" ]; then
    php artisan key:generate
fi

# Run migrations
php artisan migrate --force

# Start supervisor
/usr/bin/supervisord -c /app/docker/supervisor/supervisor.conf

Supervisor then does two things: it runs the cron and Octane. The supervisor configuration looks like this:

[supervisord]
nodaemon=true

[program:cron]
command=/usr/sbin/crond -f -l 8
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
autorestart=true

[program:octane]
command=php artisan octane:frankenphp
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
autorestart=true

For our cron we also need to copy the crontab into the container using RUN /usr/bin/crontab /app/docker/crontab in the dockerfile.

The crontab looks like this:

* * * * * php /app/artisan schedule:run

Building the container

With the docker build command we can build the docker file. The -t option provides a tag and the dot at the end is the directory where the Dockerfile is located.

docker build -t vigilant .

Persisting the public folder

In our compose file, we have bound the public directory to a Docker volume so that it is persistent. But when we do that new files from our repository will not be picked up. In order to solve this we'll add a step to our Dockerfile and a step to our entrypoint.

In our Dockerfile ,we will copy the content of the public directory to a temporary folder:

RUN mkdir /tmp/public/
RUN cp -r /app/public/* /tmp/public/

Each time the container starts we can then copy those files to the public folder so that they get created on our mounted volume.

cp -f -r /tmp/public/* /app/public

The only downside is that deleted files from your repository will not be deleted in the volume.

Putting it all together

We currently have built an image for our Laravel application but we are missing a few key components such as a database. We can use Docker compose to create a single file wich defines what our application needs to run

Docker Compose is a tool that simplifies the management of multi-container Docker applications. With Docker Compose, you can define and orchestrate multiple services (such as web servers, databases, and queue runners) within a single YAML configuration file. This file, typically named docker-compose.yml, allows you to specify the services your application needs, along with their configurations, dependencies, and networking.
To setup your application you will the only need this compose file to start the entire stack with all it's dependencies.

Let's start by creating the docker-compose.yml in the root of the project and adding our application.

services:
    app:
        build:
            context: .
        volumes:
            - type: bind
              source: ./.env
              target: /app/.env
            - ./storage:/app/storage
            - public:/app/public
        restart: always
        working_dir: /app

volumes:
    public:

We define a service called app and tell Docker compose that the Dockerfile to build the image is located in the same directory as our compose file. Then we bind three volumes:

  • Environment variable

  • Storage and public folders for persisting files

We tell compose that we always want to restart the container if it stops and we'll set the working directory.

If we start it up like this, using docker compose up in our project directory, we will see that compose will build the image and start running our entrypoint script. Our entrypoint will then start Octane and cron.

But how do we access it? Right now we can't! That is because we did not bind any ports. We have to define what containers and what ports it should open. An alternative for this is to use something like Traefik which is a reverse proxy. With Traefik you can add labels to your compose file to tell it on what URL the service should be accessible without configuring ports for each service.

But for now we will just open the Octane port:

services:
    app:
        build:
            context: .
        volumes:
            - type: bind
              source: ./.env
              target: /app/.env
            - ./storage:/app/storage
            - public:/app/public
        restart: always
        working_dir: /app
        ports:
            - "8000:8000"

volumes:
    public:

When we run the container now we see that it is accessible on port 8000, great! But our application still is missing the services it needs. Let's add a database and redis container.

services:
    app:
        build:
            context: .
        volumes:
            - type: bind
              source: ./.env
              target: /app/.env
            - ./storage:/app/storage
            - public:/app/public
        restart: always
        working_dir: /app
        networks:
            - vigilant
        healthcheck:
            test: curl --fail http://localhost || exit 1
            interval: 30s
            timeout: 10s
            retries: 5
        ports:
            - "8000:8000"

    mysql:
        image: mysql:8.0
        restart: always
        environment:
            - MYSQL_DATABASE=vigilant
            - MYSQL_ROOT_PASSWORD=password
        volumes:
            - database:/var/lib/mysql
        networks:
            - vigilant

    redis:
        image: redis:7
        restart: always
        volumes:
            - redis:/data
        networks:
            - vigilant

networks:
    vigilant:

volumes:
    public:
    database:
    redis:

As you can see, I've also created a network so that I have some control over which containers can talk to each other. This also means that we do not have to open ports. But how does our application container connect to MySQL and Redis? We use the container name! In our environment file which we have bound to the application container:

DB_CONNECTION=mysql
DB_HOST=database
DB_PORT=3306

REDIS_HOST=reds
REDIS_PORT=6379

When we'll startup the containers using docker compose up now we see that they all start to run and be able to connect to eachother. One final piece is missing which is Horizon. We could add it to the supervisor but then when we scale we are forced to scale the Octane server and Horizon. Ideally we'd be able to spin up a second container just for Horizon. So that is what we'll do by overwriting the entrypoint in our compose file:

services:
    ...
    horizon:
        build:
            context: .
        volumes:
            - type: bind
              source: ./.env
              target: /app/.env
              read_only: true
            - ./storage:/app/storage
            - public:/app/public
        restart: always
        working_dir: /app
        networks:
            - vigilant
        entrypoint: ["php", "artisan", "horizon"]

This is great, we can use docker-compose scale horizon=3 to multiply the Horizon containers to get more processing power!

Healthchecks and startup order

You may have noticed that your application container can start before your database. Or that Horizon may start before Redis is up which will give errors. It would also be nice know if the container is healthy. We can combine these two things fairly easily by adding health checks and dependencies to our compose file:

services:
    app:
        build:
            context: .
        volumes:
            - type: bind
              source: ./.env
              target: /app/.env
            - ./storage:/app/storage
            - public:/app/public
        restart: always
        working_dir: /app
        networks:
            - vigilant
        healthcheck:
            test: curl --fail http://localhost || exit 1
            interval: 30s
            timeout: 10s
            retries: 5
        depends_on:
            mysql:
                condition: service_healthy
        ports:
            - "8000:8000"

    horizon:
        build:
            context: .
        volumes:
            - type: bind
              source: ./.env
              target: /app/.env
              read_only: true
            - ./storage:/app/storage
            - public:/app/public
        restart: always
        working_dir: /app
        networks:
            - vigilant
        entrypoint: ["php", "artisan", "horizon"]
        depends_on:
            mysql:
                condition: service_healthy
            redis:
                condition: service_healthy

    mysql:
        image: mysql:8.0
        restart: always
        environment:
            - MYSQL_DATABASE=vigilant
            - MYSQL_ROOT_PASSWORD=password
        volumes:
            - database:/var/lib/mysql
        networks:
            - vigilant
        healthcheck:
            test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
            interval: 10s
            timeout: 20s
            retries: 10

    redis:
        image: redis:7
        volumes:
            - redis:/data
        networks:
            - vigilant
        healthcheck:
            test: [ "CMD", "redis-cli", "ping" ]

networks:
    vigilant:

volumes:
    public:
    database:
    redis:

As you can see we've added commands to check if Redis and MySql are working. Then on the app and Horizon containers we've added a depends_on which tells Docker compose to wait untill the services are healthy before starting.

A note on deploying without Octane

When not using Octane, you need to replace it with PHP-FPM. You can then add a nginx container to serve the requests to FPM.

Feedback

If you find any errors in this article, please report them on Discord (see the link in the footer) so that we can correct them!

Try Vigilant
Deploy on your own infrastructure