Dockerizing a Laravel Octane application
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!