Nick Pratley

Building Laravel 8 for Production Release with Horizon and Octane using Github Actions

NickNick

So you have built a shiny new web app and disabled personal teams, it’s time to build and deploy!

In this article, we will learn how to set up our Laravel 8 application for builds with GitHub Actions.

In a future article (I will link here once written) we will go over deploying this to a docker swarm cluster on every push to master and deploying feature branches on each pull request to master.

Now we can get to the fun part! Let’s start by cloning the repo from the last story, and we will use this as a boilerplate.

git clone git@github.com:DevLAN-io/laravel-boilerplate.git

Lets now run a composer install to get everything going. Because I am running the macOS Monterey beta without PHP — we will use a Docker container for this. We will also copy the environment file and remove some unneeded services.

docker run — rm -it -v $(pwd):/opt -w /opt laravelsail/php80-composer:latest bash -c “composer install && cp .env.example .env && php ./artisan sail:install — with=mysql,redis,mailhog”

Now that we have cloned and set up the repo, let us boot the app and continue.

./vendor/bin/sail up -d
./vendor/bin/sail artisan key:generate
./vendor/bin/sail artisan migrate

Now that the app is running locally, we can continue

Let us add some automatic changes to the welcome page, so we can see the build version. We’ll follow SemVer for this project and use GitVersion to automatically calculate this based on git tags.

Let us create a base version.txt in the root directory — this file will be replaced by a GitHub action in every build and stored in the container. It will be used for all local requests whilst in development.

Let’s now create a simple config file to retrieve version information. In config/version.php

<?php

return json_decode(file_get_contents(__DIR__ . "../version.txt"), true);

In resources/views/welcome.blade.php, on line 126, replace
Laravel v{{ Illuminate\Foundation\Application::VERSION }} (PHP v{{ PHP_VERSION }})
with
App v{{ config(‘version.InformationalVersion’) }}
And reload. Yay!

  • Facebook
  • Twitter
  • LinkedIn

Let’s get Laravel Octane and Horizon installed now. For Octane, we will use Swoole as the application server.

./vendor/bin/sail composer require laravel/octane laravel/horizon
./vendor/bin/sail artisan horizon:install
./vendor/bin/sail artisan octane:install <- Select Swoole when asked

We now need to edit the dockerfiles for laravel sail to create a production stack — we will base this on the docker-swoole image and add a few changes to keep it relatively small. Let’s start by exposing them.

./vendor/bin/sail artisan sail:publish

Create the docker/production directory, and add the following files.

# docker/production/Dockerfile
FROM phpswoole/swoole:php8.0-alpine

LABEL maintainer="Nick Pratley"

WORKDIR /var/www/html

ENV TZ=UTC
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

RUN apk add --no-cache supervisor icu-dev \
&& apk add --no-cache --virtual .build-deps linux-headers \
make automake autoconf gcc g++ zlib-dev bzip2-dev \
libzip-dev libxml2-dev gmp-dev openssl-dev yaml-dev \
&& docker-php-ext-install mysqli pdo_mysql pcntl intl \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del .build-deps

COPY docker/production/start-container /usr/local/bin/start-container
COPY docker/production/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/production/php.ini /etc/php/8.0/cli/conf.d/99-sail.ini
RUN chmod +x /usr/local/bin/start-container

ADD . /var/www/html

EXPOSE 80

ENTRYPOINT ["start-container"]

This Dockerfile will build our stack and copy the application files over. We will install the composer and nodejs dependencies as a build step to slim the image down, and so we can cache them in a GitHub workflow.

; docker/production/php.ini[PHP]
post_max_size = 100M
upload_max_filesize = 100M
variables_order = EGPCS

Just a few changes to PHP configuration

# docker/production/start-container#!/usr/bin/env sh

if [ ! -d /.composer ]; then
mkdir /.composer
fi

if [ ! -d /var/log/supervisor/ ]; then
mkdir /var/log/supervisor/
fi

chmod -R ugo+rw /.composer

until nc -z -v -w30 $DB_HOST $DB_PORT; do echo "Waiting for database connection..."; sleep 1; done
until nc -z -v -w30 $REDIS_HOST $REDIS_PORT; do echo "Waiting for redis connection..."; sleep 1; done

if [ $# -gt 0 ]; then
exec "$@"
else
php artisan migrate --force && php artisan config:cache && php artisan route:cache && php artisan view:cache && php artisan horizon:terminate
/usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
fi

We use two quick loops to make sure the MySQL and Redis containers are up (just a simple response on the respective ports test) before starting each service. This is a hack workaround but saves ~ 400Mb on the container size without installing Redis and MySQL client.

# docker/production/supervisord.conf[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid

[program:php]
command=/usr/bin/env php -d variables_order=EGPCS /var/www/html/artisan octane:start --server=swoole --host=0.0.0.0 --port=80
user=root
environment=LARAVEL_SAIL="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

Supervisord runs in the web container and starts our Swoole server. We configure that here.

We need to add a .dockerignore file now, which makes sure our git and node module folders do not make it into the build.

# .dockerignore**/.env
**/node_modules
**/.git
**/.DS_Store

It’s time to set up the GitHub action and run a test build! Let us create the workflow .github/workflows/build.yml and then add the build script.

This build file does a few helpful things

  1. Runs GitVersion to generate version.txt
  2. Installs Node dependencies and runs Laravel Mix in production mode to generate assets
  3. Installs Composer packages, which will get cached and copied into the container
  4. Logs into the GitHub Container Registry
  5. Builds and pushes your app container
    Pull Requests will get built and tagged, as an easy disposable way to verify new changes.

Let’s test the GitHub Action. We will create a pull request to the master branch which should build that container, and we will then merge that request into the master branch to build the production version. On each instance, we will check the version.txt file to see what it looks like.

Let switch to a new branch, add our changes, commit and push, and then create a pull request.

git checkout -b pull-request-test
git add .
git commit -m ‘testing builds’
git push origin pull-request-test

We get a URL to create a pull request, let’s head there and create it!
https://github.com/DevLAN-io/laravel-boilerplate/pull/1

This will start our action, and hopefully (in about 5 minutes) the container will be built and ready for use. You can see the output in the GitHub Actions tab.

  • Facebook
  • Twitter
  • LinkedIn
GitHub Action showing our application is building

Ok — looks like that was built successfully the first time. (Just Kidding! I have ~100 commits in a private project getting to this point over the last ~week.)

We can now see the package was built, so let’s create a docker stack to test this locally.

https://github.com/DevLAN-io/laravel-boilerplate/pkgs/container/laravel-boilerplate

We need to create a docker-compose.yml file, along with a config file. We will just borrow the config file from the repo for testing. Make sure you shut down the dev stack as we need those ports.

sail down
cd ..
mkdir test-stack
cd test-stack
cp ../laravel-boilerplate/.env .

Create the docker-compose.yml file with the following contents

We can now run the stack, and see what happens!

source .env
docker-compose up -d
➜ test-stack docker-compose logs laravel.test
Attaching to test-stack_laravel.test_1
laravel.test_1 | Waiting for database connection...
laravel.test_1 | Waiting for database connection...
laravel.test_1 | Waiting for database connection...
laravel.test_1 | Waiting for database connection...
laravel.test_1 | Waiting for database connection...
laravel.test_1 | Waiting for database connection...
laravel.test_1 | Waiting for database connection...
laravel.test_1 | Waiting for database connection...
laravel.test_1 | Waiting for database connection...
laravel.test_1 | mysql (192.168.0.3:3306) open
laravel.test_1 | redis (192.168.0.2:6379) open
laravel.test_1 | Migration table created successfully.
laravel.test_1 | Migrating: 2014_10_12_000000_create_users_table
laravel.test_1 | Migrated: 2014_10_12_000000_create_users_table (51.13ms)
laravel.test_1 | Migrating: 2014_10_12_100000_create_password_resets_table
laravel.test_1 | Migrated: 2014_10_12_100000_create_password_resets_table (42.19ms)
laravel.test_1 | Migrating: 2014_10_12_200000_add_two_factor_columns_to_users_table
laravel.test_1 | Migrated: 2014_10_12_200000_add_two_factor_columns_to_users_table (41.36ms)
laravel.test_1 | Migrating: 2019_08_19_000000_create_failed_jobs_table
laravel.test_1 | Migrated: 2019_08_19_000000_create_failed_jobs_table (37.00ms)
laravel.test_1 | Migrating: 2019_12_14_000001_create_personal_access_tokens_table
laravel.test_1 | Migrated: 2019_12_14_000001_create_personal_access_tokens_table (56.06ms)
laravel.test_1 | Migrating: 2020_05_21_100000_create_teams_table
laravel.test_1 | Migrated: 2020_05_21_100000_create_teams_table (38.13ms)
laravel.test_1 | Migrating: 2020_05_21_200000_create_team_user_table
laravel.test_1 | Migrated: 2020_05_21_200000_create_team_user_table (36.48ms)
laravel.test_1 | Migrating: 2020_05_21_300000_create_team_invitations_table
laravel.test_1 | Migrated: 2020_05_21_300000_create_team_invitations_table (93.80ms)
laravel.test_1 | Migrating: 2021_05_04_142657_create_sessions_table
laravel.test_1 | Migrated: 2021_05_04_142657_create_sessions_table (91.24ms)
laravel.test_1 | Configuration cache cleared!
laravel.test_1 | Configuration cached successfully!
laravel.test_1 | Route cache cleared!
laravel.test_1 | Routes cached successfully!
laravel.test_1 | Compiled views cleared!
laravel.test_1 | Blade templates cached successfully!
laravel.test_1 | 2021-07-11 03:00:38,631 INFO Set uid to user 0 succeeded
laravel.test_1 | 2021-07-11 03:00:38,634 INFO supervisord started with pid 65
laravel.test_1 | 2021-07-11 03:00:39,637 INFO spawned: 'php' with pid 67
laravel.test_1 |
laravel.test_1 | INFO Server running…
laravel.test_1 |
laravel.test_1 | Local: http://0.0.0.0:80
laravel.test_1 |
laravel.test_1 | Press Ctrl+C to stop the server
laravel.test_1 |
laravel.test_1 | 2021-07-11 03:00:40,818 INFO success: php entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)

Fantastic! The container has started and entered our loops to wait for MySQL and Redis. Once MySQL came up, database migrations were completed and the application booted.

We check the application in Chrome again — http://localhost/, and we can see it is running the code we pushed to that pull request. Yay!

  • Facebook
  • Twitter
  • LinkedIn
Our Laravel app, showing the version from GitVersion

Let’s go back to GitHub, merge the pull request, and now we wait for another ~2 minutes or so — this time thanks to the caching of composer modules, it will be much quicker.

  • Facebook
  • Twitter
  • LinkedIn
We can see the GitHub action is now running after merging the pull request.

Sweet, that worked and we have a new package to run. Let’s update the docker-compose.yml file to use this new image and redeploy the stack.

Let’s change
image: ghcr.io/devlan-io/laravel-boilerplate:pr-1 to image: ghcr.io/devlan-io/laravel-boilerplate:master

And re-create the stack

sed -i '' 's/pr-1/master/' docker-compose.yml
docker-compose up -d --force-recreate
docker-compose logs laravel.test
Attaching to test-stack_laravel.test_1
laravel.test_1 | mysql (192.168.0.3:3306) open
laravel.test_1 | redis (192.168.0.2:6379) open
laravel.test_1 | Nothing to migrate.
laravel.test_1 | Configuration cache cleared!
laravel.test_1 | Configuration cached successfully!
laravel.test_1 | Route cache cleared!
laravel.test_1 | Routes cached successfully!
laravel.test_1 | Compiled views cleared!
laravel.test_1 | Blade templates cached successfully!
laravel.test_1 | 2021-07-11 03:18:44,641 INFO Set uid to user 0 succeeded
laravel.test_1 | 2021-07-11 03:18:44,643 INFO supervisord started with pid 48
laravel.test_1 | 2021-07-11 03:18:45,648 INFO spawned: 'php' with pid 50
laravel.test_1 |
laravel.test_1 | INFO Server running…
laravel.test_1 |
laravel.test_1 | Local: http://0.0.0.0:80
laravel.test_1 |
laravel.test_1 | Press Ctrl+C to stop the server
laravel.test_1 |
laravel.test_1 | 2021-07-11 03:18:46,820 INFO success: php entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)

We can see that the container boots a lot quicker now too, as we are just re-using the MySQL container.

Let’s check our app again, and we should see that the version string has been updated again to reflect we are back on the master branch.

  • Facebook
  • Twitter
  • LinkedIn

Fantastic! Suppose we need to increase the SemVer version, we can add a version tag to the repo, push to master and that will be updated. I’ll leave that up-to-the reader for now 🙂

That is it for today, thanks for reading! In the next article, we will learn how to automate deployments of feature branches from pull requests and the master branch of our app into production.

Nick
Author

Comments 1

Share This