Adding CI and CD to a PHP Command Line App With Docker

Written by: Karl Hughes
8 min read

Be sure to take a look at Part 1: Building a PHP Command Line App with Docker before continuing this walkthrough.

At this point, we have already set up a PHP command-line application using Laravel's Artisan commands and Docker Compose. The rest of this tutorial will go through the process of setting up Codeship Pro to automate your continuous integration and deployments.

More specifically, here's what we're going to cover:

  • Using Jet to set up our environment and run our tests locally

  • Configuring Codeship Pro to run our tests whenever new code is pushed

  • Automatically deploying updates to our server after tests have passed

Continuous Integration

With our application and test suite running locally, the next thing we want to do is set up some kind of continuous integration system. You could set up your own servers to do this, but that's a fair amount of work, so I typically recommend a service like Codeship Pro.

Running tests locally with Jet

Before you run your tests on Codeship, I recommend setting up a local version of their CI platform, Jet. This will allow you to get things working faster; your configuration files might need some tweaking as your application evolves.

Once you've installed Jet, create two new files in the root directory of your project:

  • codeship-services.yml - A variation of your docker-compose.yml file specifically for Codeship

  • codeship-steps.yml - Instructions for the order and commands to run during continuous integration

The codeship-services.yml file is almost identical to our docker-compose.yml file:

version: "2.0"
services:
  # PHP Application
  app:
    build: .
    links:
      - database
    encrypted_env_file: .env.encrypted
    command: cron -f
  # Database
  database:
    image: mariadb
    encrypted_env_file: .env.encrypted
  # Composer
  composer:
    image: composer/composer
    volumes:
      - ./:/app

And the codeship-steps.yml file is pretty simple for this example because we want to run all our commands serially (one after the other). You can also run some steps in parallel if your application allows.

- type: serial
  steps:
  - service: composer
    command: install
  - service: app
    command: bash docker/codeship-run.sh

As you can see, the codeship-steps.yml file calls a shell script that we haven't created yet. This script is going to make sure the app and database containers are up, the database migrations are run, and that the tests pass. It may sound like a lot, but it's actually a pretty simple file. Put this script in ./docker/codeship-run.sh:

#!/usr/bin/env bash
## Ensure that the database is up and running
function test_database {
  mysqladmin -h"$DB_HOST" -u"$DB_USERNAME" -p"$DB_PASSWORD" ping
}
count=0
until ( test_database )
do
  ((count++))
  ## This will check up to 100 times.
  if [ ${count} -gt 100 ]
  then
    echo "Services didn't become ready in time"
    exit 1
  fi
  ## And the script waits one second between each try
  sleep 1
done
## Create the database
mysql -h"$DB_HOST" -u"$DB_USERNAME" -p"$DB_PASSWORD" -e 'CREATE DATABASE IF NOT EXISTS laravel'
## Run migrations
php artisan migrate
## Run the test suite
vendor/bin/phpunit

The first thing this script does is attempt to connect to the database. Codeship's software automatically brings up the app and database containers, but MySQL takes a few seconds to initialize, so we have to retry the test_database() function until we get a success (or 100 tries). This is outlined in more detail in Codeship's Docker documentation.

Once the script is able to verify a database connection, it creates the default database (called laravel). Then it runs the migrations to create the database table and the test suite via PHPUnit.

Finally, in order to test that this configuration works and the tests will pass, we can run everything with Jet:

$ jet steps

If everything is working, you should see a bunch of output as the containers are built and commands are run with a success message at the end:

{ContainerRunStdout=step_name:"serial_bash_docker/codeship-run.sh" service_name:"app"}: PHPUnit 5.7.19 by Sebastian Bergmann and contributors.
{ContainerRunStdout=step_name:"serial_bash_docker/codeship-run.sh" service_name:"app"}: .                                                                   1 / 1 (100%)
Time: 1.09 seconds, Memory: 12.00MB
OK (1 test, 1 assertion)
{StepFinished=step_name:"serial_bash_docker/codeship-run.sh" type:STEP_FINISHED_TYPE_SUCCESS}
$

Connecting our repository to Codeship

If you haven't yet, add and commit your local code and push it to a new repository on GitHub or Bitbucket. Codeship automatically pulls code from your repository (public or private) each time you push changes, so now we just need to give Codeship the repository to watch.

Create a new project in Codeship, and connect it to your repository.

When prompted, choose "Codeship Pro" as your project type.

Now your project is linked to Codeship, and the next time you push your code, Codeship will attempt to build it and run your tests using the same codeship-steps.yml and codeship-services.yml files that you used locally. The only problem at this point is that you were using your local .env file, and that should not be committed to your repository. Fortunately, there's an easy way to set environmental variables without compromising security.

Encrypting your environmental variables

Since it's best practice not to push our .env file up to the continuous integration server, we need to come up with a way to securely pass variables up to Codeship. That's where an encrypted .env file comes in.

First, find your AES key on Codeship (it's in the General page of your project settings), and put it in a file at the root of your local project called codeship.aes. Don't forget to add this file to your .gitignore file, as it's an encryption key that should not be shared.

Next, update your codeship-services.yml file to use an encrypted .env file instead of the plain text one:

version: "2.0"
services:
  # PHP Application
  app:
    build: .
    links:
      - database
    encrypted_env_file: .env.encrypted
    command: cron -f
  # Database
  database:
    image: mariadb
    encrypted_env_file: .env.encrypted
  # Composer
  composer:
    image: composer/composer
    volumes:
      - ./:/app

Now, use Jet to encrypt your .env file as .env.encrypted, commit that file to the repository, and push it to your remote repository:

$ jet encrypt .env .env.encrypted
$ git add -A && git commit -am "Adding codeship config"
$ git push origin

You should see Codeship running your build:

You can click into the build and see details about each step as well:

If everything above was done correctly, you should eventually get a successful build:

!Sign up for a free Codeship Account

Automated Deployments

A continuous integration service that tells us if our tests are passing is great, but in order to get the most value out of Codeship, it's very useful to have it automate our deployments as well. In order to do this, we'll need a few things:

  • The code repository manually configured and deployed on a server

  • An SSH key on the server that has permission to pull from our repository

  • A script on the server to update the code and restart the containers

With those things in place, we will be able to build a deployer container whose job is to SSH into the server and run the update script at the end of our build process.

I should say that this is just one way to deploy code using containers, and it may not be the best way for your production environment. Another option might be to use a container registry like Docker Hub to build your images and then update the containers directly from there. I've found that the best practices for Docker in production are still being established, so I favor this approach because it's simple and works for us.

Here's that process that I use in more detail:

Manually deploying code for the first time

This step will vary quite a bit depending on your hosting provider, but basically you will need a server provisioned that has Git, Docker, and Docker Compose installed. SSH into the server and:

  1. Create a new SSH key.

  2. Give the SSH key access to read from your repository.

  3. Clone your code's repository.

  4. Set up your .env file with a new APP_KEY and database password.

  5. Build and run the containers with Docker Compose: docker-compose up -d --build.

You should now be able to run docker ps and see the same two containers running as when we set this project up locally for the first time.

Adding a script to update the server's code

Now on the local version of your code, we want to add a shell script that will pull updates from your repository and restart the containers:

#!/usr/bin/env bash
## Pull the latest code
git pull origin master
## Rebuild the containers
docker-compose up -d --build
## Run migrations
docker exec dockerphpcliexample_app_1 php artisan migrate --force

This file should be called deploy.sh and be put into the docker/ folder within your repository.

At this point, it's probably a good idea to make sure this file is on the server, so push your changes to the repository and pull them from the server. Test the script out by running $ bash docker/deploy.sh from the server and making sure that your containers are still working.

Creating a deployer container

As I mentioned above, we now need a container to run this deploy script remotely from Codeship's CI server after the build and test process has completed. Create a new folder called deployer/ in your repository with a Dockerfile, .env file, and a shell script called execute.sh:

Dockerfile

FROM alpine:latest
# Install openssh
RUN apk update && apk add openssh
# Prep for the ssh key
RUN mkdir -p "$HOME/.ssh"
RUN touch $HOME/.ssh/id_rsa
RUN chmod 600 $HOME/.ssh/id_rsa
# Add the shell script
COPY execute.sh execute.sh
CMD sh execute.sh

.env

USER=<SERVER_SSH_USERNAME>
HOST=<SERVER_HOST>
PRIVATE_SSH_KEY=<SSH_KEY (with linebreaks replaced with `\n`)>

See Codeship's article on SSH key authentication for more details.

execute.sh

#!/usr/bin/env bash
echo -e $PRIVATE_SSH_KEY >> $HOME/.ssh/id_rsa
ssh -t -oStrictHostKeyChecking=no $USER@$HOST "cd docker-php-cli-example &amp;&amp; sh docker/deploy.sh"

This container will use the variables from the .env file to SSH into the server and run the deploy script.

Making Codeship Pro run the deployer

In order to make Codeship aware of the deployer, we'll need to add it to the codeship-services.yml file:

# Deployer
  deployer:
    build: ./deployer
    encrypted_env_file: deployer/.env.encrypted

And to the codeship-steps.yml file:

- service: deployer
    command: sh execute.sh

We also want to encrypt the new deployer/.env file so that we can commit it to the repository without exposing our server's SSH key. So just like we did with the main code repository, we're going to use Jet to encrypt the environment file:

$ jet encrypt deployer/.env deployer/.env.encrypted

Finally, push these updates to your GitHub repository and make sure Codeship successfully builds and deploys your code.

If you have any problems running this tutorial, feel free to add an issue to the GitHub repository I set up or find me on Twitter.

Stay up to date

We'll never share your email address and you can opt out at any time, we promise.