Joshua Pangborn

May 18, 2024

Deploying a Laravel App with Kamal

I’ve been working on a new app in Laravel and was ready to deploy it so I could begin using an early version. Kamal has been on my radar as a deployment tool to simplify server setup and deployment, while maintaining flexibility on future choices, like cloud vendor or even my own servers. 

The Kamal documentation and early tutorial videos make it look so easy to provision a new server and deploy your application, whether to one server or more. But reality is a bit more complicated. I’ve build and managed applications in AWS with load balancers and auto-scaling. I’ve set up Nginx and PHP-FPM on virtual private servers from scratch. I used Docker to run containers locally. But I still had a big gap in knowledge that was needed to fully get an app deployed with Kamal. I had not built a Docker image. I had not worked with Traefik. These are core concepts needed to work proficiently with Kamal. 

As I started the process, I found a lack of comprehensive documentation that brought together the necessary information. No doubt, this is because of how new Kamal is. I was able to piece it all together over a few weeks and get to a working process. In this series, I am going to detail that process and hopefully you’ll have an easier time getting up and running.

Preparing a Docker Image for Laravel

If you have only ever consumed already created Docker images, there is some things to learn before you ever even get to using Kamal. You will need to prepare a Docker image for your Laravel application that Kamal can build and deploy for you. I started with Kamal, then had to backtrack and learn this. Searching for information on creating a Docker image for Laravel will yield a lot of information, unfortunately not all of it is relevant. You’ll come across a ton of information about running Laravel in Docker locally. Most of this will use a `docker-compose.yml` file to structure your application. Here is the first thing to learn when preparing to deploy with Kamal. Kamal’s `deploy.yml` configuration does many of the same things as `docker-compose.yml`, but for deployment. So all of the tutorials on running Laravel locally via `docker-compose.yml` are not particularly helpful. You will need to prepare a `Dockerfile` to define your Docker image build. This is was Kamal will use to build your image and deploy your application.

Most of the information I found on preparing a Dockerfile for Laravel was older or not particularly useful. Most had base images with no discussion of why the base image was chosen. I found the [ServerSideUp PHP Docker Images](https://serversideup.net/open-source/docker-php/) and chose to use these as the basis of my Docker image. ServerSideUp has several variations of Docker images to use as the starting point. I did some testing with both the Nginx with PHP-FPM image and the Nginx Unit image. (There is also an Apache with PHP-FPM image if that is your preference.) I ended up choosing the Nginx Unit image as my base. There are some slight differences between the images and it may impact your Dockerfile setup.

Below is the Dockerfile I setup. I’ll walkthrough what I am doing and why. At a high level, you are going to start with a base image and make some configuration changes to prepare the container for your application.

FROM serversideup/php:8.3-unit

ENV SSL_MODE=off
ENV PHP_OPCACHE_ENABLE=1

USER root

RUN apt-get update && apt-get install -y git

USER www-data

COPY --chmod=755 ./entrypoint.d/ /etc/entrypoint.d/
COPY --chown=www-data:www-data . /var/www/html

The first line simply defines which base image to use. In this case, I am using the `serversideup/php:8.3-unit` image.

The next two lines set a couple of environment variables that determine how the image is configured. 

ENV SSL_MODE=off
ENV PHP_OPCACHE_ENABLE=1

The availability of these environment variables is going to depend on the base image. For more information on the ServerSideUp images and the available environment variables, see their Environment Variable Specifications - Docker PHP - Server Side Up documentation.

In my case, I am ensuring that SSL is turned off, since the container will be behind Traefik, a reverse proxy and either Traefik or an external load balancer will handle the SSL. Since this image is intended for production deployment, I am enabling the PHP Opcache for performance.

The next three lines are necessary for my Laravel application. They handle installing additional packages that are not included in the base image.

USER root

RUN apt-get update && apt-get install -y git

USER www-data

The ServerSideUp images are configured for Laravel and therefore include `composer` so that you can install dependencies within the container. Typically when doing this on a production server, you would use `—prefer-dist` so that you don’t load packages from source. In my application, I am using a package that I updated and need to load from GitHub. So I first need to switch the user to `root`, install git, and then switch back to `www-data`. (The ServerSideUp images from as the unprivileged `www-data` user by default.) 

The final two lines copy files from the local working directory into the container image.

COPY --chmod=755 ./entrypoint.d/ /etc/entrypoint.d/
COPY --chown=www-data:www-data . /var/www/html

The first copies any scripts for further configuration from the `entrypoint.d` directory in the application repository into `/etc/entrypoint.d/` directory and sets the appropriate permissions. This directory is provided by the ServerSideUp image and any scripts copied here will be automatically run when the container is booted. I’ll cover these scripts in more detail in the section below. It is worth mentioning that the ServerSideUp images also provide an environment variable, called `AUTORUN_ENABLED`, that when set to `true` runs the standard `php artisan` commands needed to prep a Laravel app during production deployment when the container boots. I have chosen to not use this and instead handle these manually via scripts in the `entrypoint.d` directory.

The second line copies the Laravel application into the web root directory in the container and sets the correct owner and group so that the files are accessible by nginx. This command will skip any files listed in your `.dockerignore`, such as your `.env` files.

You can test building this Dockerfile locally with the following command:

docker build .

In my case, the scripts that I have included in `entrypoint.d` rely on some Kamal functionality, so the build will fail when built with Docker directly instead of as a part of the Kamal deployment. But it will help you identify any issues with the Dockerfile setup. There are some differences if you choose to go with one of the other ServerSideUp images. The most significant is the process for running scripts as the container boots is using the Nginx and PHP-FPM image.

At this point you have a Docker image of you Laravel application that can be used in the Kamal deployment process, which we will cover next.

A Basic Kamal Deployment

We will start the Kamal walkthrough by preparing a single server deployment for Laravel without SSL. Here is the basic Kamal `deploy.yml` file. Like with the Dockerfile, I walk through this file and add some comments on specific lines. The bulk of this file is very similar to the example `deploy.yml` used in the Kamal documentation. 

# Name of your application. Used to uniquely configure containers.
service: fold

# Name of the container image.
image: jpangborn/fold

# Deploy to these servers.
servers:
  - 68.183.55.190

# Credentials for your image host.
registry:
  # Specify the registry server, if you're not using Docker Hub
  # server: registry.digitalocean.com

  # Always use an access token rather than real password when possible.
  username:
    - KAMAL_REGISTRY_USERNAME
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  clear:

  secret:
    - LARAVEL_ENV_ENCRYPTION_KEY

labels:
  traefik.http.routers.fold-web.rule: Host(`app.domain.tld`)
  traefik.http.services.fold-web.loadbalancer.server.port: 8080

# Use accessory services (secrets come from .env).
accessories:
  db:
    image: mysql:8.0
    host: 68.183.55.190
    port: 3306
    env:
      clear:
        MYSQL_ROOT_HOST: '%'
      secret:
        - MYSQL_ROOT_PASSWORD
    files:
      - database/init.sql:/docker-entrypoint-initdb.d/setup.sql
    directories:
      - data:/var/lib/mysql

# Configure a custom healthcheck (default is /up on port 3000)
healthcheck:
  path: /up
  port: 8080
  max_attempts: 10
  interval: 20s

The first thing that you will need to do is make sure you have a container registry account setup and the username and password. Since I was new to creating Docker images, this setup wasn’t clear to me. As you can see, I ended up going with Docker Hub for the container registry. This is the default Kamal and will be the easiest to setup. You’ll need to visit Docker Hub, create an account and then add the Docker Hub username and password to your `.env` file.

KAMAL_REGISTRY_USERNAME=jpangborn
KAMAL_REGISTRY_PASSWORD=dckr_########################

Kamal will use the values to connect to the registry and push the built image there so that it can be pulled and booted up on the server during deployment.

With the container registry setup, let’s look at the first few lines of the `deploy.yml`.

# Name of your application. Used to uniquely configure containers.
service: fold

# Name of the container image.
image: jpangborn/fold

# Deploy to these servers.
servers:
  - 68.183.55.190

These lines are pretty straightforward, except perhaps that if you are new to Docker, you’ll need to know that the `image` name will need to contain your Docker Hub username.

You need to have a server with a public IP address that you can access via SSH to deploy to. The Kamal documentation and videos are very good at describing this step. I am using a simple VPS setup on DigitalOcean currently. And at this point, we only have one server IP address, but we can easily add more in the future show we need to scale out horizontally.

# Credentials for your image host.
registry:
  # Specify the registry server, if you're not using Docker Hub
  # server: registry.digitalocean.com

  # Always use an access token rather than real password when possible.
  username:
    - KAMAL_REGISTRY_USERNAME
  password:
    - KAMAL_REGISTRY_PASSWORD

The next set of lines simple tell Kamal to pull the username and password for you chosen container registry from the `.env` file where we added them previously. Super simple.

env:
  clear:

  secret:
    - LARAVEL_ENV_ENCRYPTION_KEY

In this section, you can specify environment variables that will be available in the container once built. Laravel make use the `.env` file to handle its environment variables, but the `.env` is not checked into source control and should be in the `.dockerignore` file to prevent its being copied to the container. So once your container is built and booted there will be no `.env` for Laravel. 

I ended up using Laravel’s environment file encryption process to generate an `.env.production.encrypted` file that I am copying into the container. I then use a script in `entrypoint.d` to decrypt the file. I am using this section of the `deploy.yml` file to tell Kamal to ensure that the `LARAVEL_ENV_ENCRYPTION_KEY` environment variable is properly added to the app container. 

Traefik

I said earlier that Kamal assumes that you are familiar with the technologies that it uses. Like creating a Docker image of your application. Another thing you may need to be familiar with is Traefik. Traefik is key to how Kamal is able to provide zero downtime deployments. It is a reverse proxy that acts as a simple load balancer on each server for the application containers. At the most basic level, when you do a deployment with Kamal, it will create a container on the server for your application and a container for Traefik. (I’m ignoring accessories and roles other than web at this point.) Traefik handles incoming traffic from the internet and routes it to the appropriate container. On your next deploy, Kamal will create a new app container alongside the existing one. Once it is healthy and able to receive traffic from Traefik, Kamal will remove the old app container. 

So Traefik needs to be able to discover your container and determine how to send traffic to it. In many cases, this happens automatically. Traefik is able to communicate with Docker to discover what other containers are running on a server. If the app container exposes one port, Traefik will see that and use it to route traffic to the app container. If the app container exposes more than one port, you will need to tell Traefik which port to use. And the how to tell Traefik which port to use was where I got the most stuck while setting up the deployment process. 

Let’s step back and look at some terms and concepts that Traefik uses. There are three that we are going to review: Entry Points, Routers, and Services. 

Entry Points are the ingress points for Traefik. These are where outside internet traffic is entering. For our purpose, these are ports exposed to the internet. By default Traefik will have an Entry Point for Port 80, allowing http traffic in. Entry Points have names. The Kamal documentation and tutorials were not clear on how the Entry Points were named. I saw various entry point names used, and using the wrong name will cause configuration issues. What I have found in my testing is that the entry point for port 80 was named `http`. 

Services are what Traefik needs to get traffic to. In our case, that will generally be a Docker container running your app. Traefik needs to know associate the container’s address and port with the service. With Kamal, the app containers are setup with a Traefik Service name that is the `service` defined in the `deploy.yml` and the role of the server. So in my `deploy.yml`, the `service` is `fold`, and the role of the app server is `web` (this is the default role name in Kamal). Therefore, once deployed Traefik will have a Service with the name `fold-web@docker`. 

Routers are the rules that determine how Traefik takes incoming traffic from an entry point and sends it to a service. A simple rule might tell all traffic coming in via the `http` entry point should be sent to the `fold-web@docker` service. 

With those concepts in place, let’s look at how we can customize Traefik’s configuration and behavior. With Kamal, the basic way configure Traefik is to set specific labels on the containers, which Traefik will see and use to adjust its configuration. What was most confusing to me was which container the label should be on. In much of the Kamal documentation, the labels were present under the Traefik configuration. It took some time to realize that I needed to put the labels on the app containers. 

Let’s look at the labels that I have on the app containers:

labels:
  traefik.http.routers.fold-web.rule: Host(`app.domain.tld`)
  traefik.http.services.fold-web.loadbalancer.server.port: 8080

The first label is telling Traefik to adjust the configuration of a router. By default, Kamal and Traefik will create a router with the `SERVICE-ROLE` name format and it will route all traffic from the Entry Point `http` (port 80) to the Service with the same name. The first label is adjusting that rule for the `fold-web` router from all traffic to only traffic to the host: `app.domain.tld`. This rule will be useful later on when we enable SSL with Traefik.

The second label is adjusting the configuration of a service. As said above, Traefik will discover the correct port for your app container if it only exposes one. But in the case of the ServerSideUp images, both the 8080 and 8443 ports are exposed. (And during my setup the `php:83-unit` image was inadvertently exposing 80 and 443 as well.) Because of this Traefik was unable to detect the correct port and was defaulting to 80 for the `fold-web` service. So this label specifies which port to use for this container, in this case 8080. If you use a different base image for your Dockerfile, you may not need to specify this label or you may need to specify a different port.

The two points of confusion in getting these labels to work was:

  1. Putting them on the correct container. In this case, the app containers.
  2. Having the correct router or service name in the rule. (`SERVICE-ROLE`)

While these are the only two labels that are necessary to deploy this app, let’s look at some other Traefik settings and labels that might be useful, especially if you run into issues and need to troubleshoot the Traefik setup.

The following can be included in the `deploy.yml` file. For consistency with the Kamal example files, place it after the `accessories` key.

traefik:
  args:
    api.dashboard: true
    api.insecure: true
    accesslog: true
    accesslog.format: json
  labels:
    traefik.enable: true
  options:
    publish:
      - 8080:8080

Kamal provides some ways to directly control Traefik and you can also add labels to the Traefik container that will adjust the Traefik configuration. With these options, we will turn on Traefik’s internal dashboard that gives you visibility into the Traefik is setup and enables more detailed logging. 

The first `arg`, `api.dashboard: true` tells Traefik to enable the dashboard. 

The second, `api.insecure: true` tells Traefik to allow access to the dashboard over http, instead of requiring https.

The third and fourth, `accesslog: true` and `accesslog.format: json` tell Traefik to log traffic and in which format to log. 

The label, `traefik.enable: true`, tells Traefik to include itself when discovering containers so that its internal services are available to its routers.

Finally, the `publish:` and following line expose port 8080 so that you can access the dashboard on that port. Be aware that by exposing this port, you are enabling anyone to access the dashboard, so it should only be done for debugging. There are ways to secure the dashboard, but this series is already too long.

The Traefik dashboard is very helpful for visualizing how all of the pieces, such as entry points, routers, and services work together. If you have run into any issues at this point, you can use the dashboard to help you identify where the issue is. Once resolved, you can remove these lines to disable the dashboard.

Health Check

# Configure a custom healthcheck (default is /up on port 3000)
healthcheck:
  path: /up
  port: 8080
  max_attempts: 10
  interval: 20s

Kamal has a basic health check that it runs in order to validate that the app container is up and running. You can customize the health check settings. For the ServerSideUp Docker images, you will need to change the port from the default of 3000 to 8080. I also increased the max attempts and the interval as I saw some inconsistencies with the default settings when building the image.

Accessories

# Use accessory services (secrets come from .env).
accessories:
  db:
    image: mysql:8.0
    host: 68.183.55.190
    port: 3306
    env:
      clear:
        MYSQL_ROOT_HOST: '%'
      secret:
        - MYSQL_ROOT_PASSWORD
    files:
      - database/init.sql:/docker-entrypoint-initdb.d/setup.sql
    directories:
      - data:/var/lib/mysql

Most applications require a database to function and Kamal makes this easy using accessories. The above portion of the `deploy.yml` is very similar to the example that comes in the default Kamal `deploy.yml`. In my case, as I am currently run the app on a single server, the accessory is also on the same server which works just fine, but it can easily be done on its own separate server if needed. 

The one thing that I’ll point out here is the files section. This takes the `database/init.sql` file and makes sure it is available to the accessory container as a file that is automatically run when the container is setup. In the `init.sql` file, I am just creating the database needed by the app. In the next section, we get back to the scripts for handling Laravel migrations. 

Connecting Everything Together

We’ve covered the script to initialize the database when the accessory container is setup. Now we’ll cover the scripts that run when the app container is booted so that Laravel is ready to go.

The ServerSideUp images provide a directory in which you can copy scripts that will be automatically executed. Recall the following line of the `Dockerfile`:

COPY --chmod=755 ./entrypoint.d/ /etc/entrypoint.d/

You can read the documentation from ServerSideUp regarding custom startup scripts at Adding your own start up scripts - Docker PHP - Server Side Up.

I have thee scripts with the following names:
  • 30-composer-install.sh
  • 31-setup-env.sh
  • 32-laravel-setup.sh

The scripts are executed in name order. There are other built in scripts that ServerSideUp image run and you can choose names that run scripts at specific points in the container process. I have chosen names that ensure the scripts are run after the web server configuration is complete and before the ServerSideUp Laravel Automations (if you choose to use them).

#!/bin/bash
set -e

echo "Install Composer Dependencies"

cd /var/www/html
composer install

The first script only runs composer install to setup dependencies for your app. You can adjust this script for your use case, such as adding the `—prefer-dist` option.

#!/bin/bash
set -e

echo "Decrypting Env File"

cd /var/www/html
php artisan env:decrypt --env=production --force
mv .env.production .env

The second script decrypts the `.env.production.encrypted` file and renames it to `.env`. This automatically uses the `LARAVEL_ENV_ENCRYPTION_KEY` environment variable that we setup in the `deploy.yml`. Now the container has the appropriate environment file.

#!/bin/bash
set -e

echo "Running Migrations"

cd /var/www/html
php artisan migrate --force

echo "Creating Caches"
php artisan optimize

The final script handles running Laravel migrations and creating appropriate caches. You can choose to use the Laravel Automations instead of this script if you choose. You can read more about them at: Laravel Automations Script - Docker PHP - Server Side Up

To use them, simply add the following line after the other ENV lines in the `Dockerfile`:

ENV AUTORUN_ENABLE=true

At this point you should everything setup to deploy your app to a server with Kamal. For a new server, run `kamal setup` to get the server ready and the application deployed. Afterwards, deploy new changes with `kamal deploy`.

The application should be available on the web and will work well if you intend to the run the application behind a load balancer. You can add additional IP addresses to the `deploy.yml` to deploy to more servers.

If you want to run the application on a single server without a load balancer, Traefik can be configured to handle the SSL with Let’s Encrypt. Here is a follow-up article with the instructions for Enabling SSL with Traefik.

I hope this helps you begin using Kamal with Laravel. If you run into any problems or have any questions, I’m happy to assist as best I can. You can reach my on Twitter at: @joshua_pangborn.