Zero-downtime deployment of your Symfony app using Capistrano and GitHub Actions

The term "Zero-down­time deploy­ment" is probably pretty self explanatory, it's deploying without your website or application going down during the process. A popular solution for achieving this is fancily called "Atomic Deployment". In this article I'll explain to you what it is and how you can set up such process yourself.

Atomic Deployment

Atomic Deployment means pre-building a new version of your project and replacing the currently deployed version with the new one instantly.

Sounds a bit abstract? Let me give you an example...

On a typical web server a directory structure might look something like this:

/var
    /www
        /public
        /...

In here the public directory is the document root, so the directory accessible by the outside world if they visit your domain name.

If you don't use any deployment strategy you'll probably initialize your Git repository in the www directory and pull your changes down there. If you're really old-skool (or your web host doesn't allow SSH access) you might even upload your files using FTP.

The problem in doing it like that is that there will always be downtime. It might be 1 second, it might be 10 seconds but it might as well be hours if something goes wrong. You might be willing to take this risk for personal projects but you surely don't want this to happen for your customers or commercial websites.

A typical directory structure for Atomic Deployment looks like this:

/var
    /www
        /current --> symlink to /var/www/current/releases/20201104141652/public
        /releases
            /20201104141652
            /20201011080511
            /...
        /shared

In here, the current directory is your document root, and is a symlink to the newest releases sub-directory.

The concept of Atomic Deployment is to create what are called "builds" or "releases". Every time new features or changes need to be deployed a new timestamped directory will be created. Within that directory a set of actions will be executed that are needed to get your website/application running, for example:

So instead of doing all of this in the live directory, we prepare a new release without your users even noticing. Since the current directory still links to the previous release, our website won't be down when we encounter an error from thinks like installing Composer dependencies or warming up the cache.

If creating the new release didn't cause any error we can then replace the current symlink and point it to the newest release and BOOM! Our website is deployed with zero downtime! Should the new release for some reason produce breaking errors you can still quickly change the symlink to the old release and try to find out what's wrong without your website being down while debugging.

The purpose of the shared directory is something I'll explain later on when we start getting practical.

What's Capistrano's role in this?

Capistrano is a tool that automates the whole Atomic Deployment process. Capistrano will SSH into your server, create the directory structure, prepare each new release, create all necessary symlinks etc. If Capistrano notices something went wrong, the build will be canceled and won't be released.

Preparing your server and your local machine

Time for some action! For this article I'm using a pre-installed DigitalOcean LAMP server but you should be able follow this tutorial with any kind of hosting you have SSH access to. I'm also assuming that you can access your server locally without a password by using an SSH key.

Since Capistrano is Ruby-based, you'll first have to install Ruby on your local machine:

brew install ruby

Should you be getting any permission errors like I did, try running:

sudo chown -R $(whoami):admin /usr/local/* \
&& sudo chmod -R g+rwx /usr/local/*

When you then try reinstalling, the errors should be gone:

brew reinstall ruby

You'll also need Bundler which is kind of the Composer of Ruby and allows us to define gems (packages) and their versions for each project:

sudo gem install bundler

Adding Capistrano to a Symfony project

Install a new Symfony project and push it to a new private repository:

symfony new test-capistrano --full
cd test-capistrano
git remote add origin YOUR-REPO
git add .
git commit -m "First commit"
git push origin master

Create a Gemfile and add these lines:

source 'https://rubygems.org'

gem 'capistrano'
gem 'capistrano-symfony'
gem 'capistrano-composer'
gem 'ed25519'
gem 'bcrypt_pbkdf'

This is the file used by Bundler and defines the Ruby dependencies (Gems) in the same way Composer uses composer.json. We obviously need Capistrano but we also add a Symfony and Composer plugin. The last two will make sure Capistrano can access your server with "newer" types of SSH keys.  

To install the dependencies on our machine run:

bundle install

Note that unlike Composer, these Ruby dependencies are installed on your machine globally, not in a vendor directory in the project itself. However, a Gemfile.lock is created for the same reasons yarn.lock or composer.lock exist.

The next step COULD be running the following command in our project directory:

cap install

This would create all the necessary configuration files and create the directory structure for a Capistrano-enabled project but since this is a tutorial we're going to create it all from scratch. This will give you a better understanding of the purpose of each file and each line of code. After you've read this all through you can always choose to run the command above in new projects.

So next up we'll need to create another special file, the Capfile:

require "capistrano/setup"
require "capistrano/deploy"
require "capistrano/symfony"
require "capistrano/composer"

What this file does is make Capistrano aware of the "modules" needed in our project. Each module will have all kinds of pre-defined tasks and configuration options.

Now we need to create a config/deploy.rb file, which is required by the capistrano/deploy module. In there we'll configure our deployment strategy.

Add this to the file:

lock "~> 3.14.0"

set :application, "my-test-project"

The "lock" line is always added automatically when you run cap install, so I just used the latest version that's installed on my machine. The Capistrano docs explain the purpose of the line perfectly: "This checks that the version of Capistrano running the configuration is the same as was intended to run it."

:application is an example on how to configure stuff. This simply defines the name of your application.

Capistrano will need to know which Git repository to clone, and which branch:

set :repo_url, "git@github.com:NAME/YOUR_REPO.git"
set :branch, 'master'

Then we want to set the location where are all directory magic will happen. This will depend on your server/hosting, and could look something like this:

set :deploy_to, "/var/www/checkout"

Now it's time to define the files and directories we want to keep when a new release is deployed, better known as "shared" files and directories.

Since a new "release" is simply a new directory containing a completely fresh clone of your repository, everything from the old release will be gone when we trigger a new deploy.

However, there's a lot of stuff you don't want to lose, for example your logs, the .env.local file, maybe some directories containing user uploads etc. These are things we always want to keep even when something new is released.

We can define those types of files and directories like this:

set :linked_files, [".env.local"]
set :linked_dirs, ["var/log", "public/media/cache", "var/uploads"]

What Capistrano does is create a shared directory containing all of these files and directories, and create the necessary symlinks within each new release. Obviously in order for this to work none of these things may exist in your repository so make sure all of them are added to your .gitignore file.

For better performance we'll also add the correct flags when running composer install:

set :composer_install_flags, '--no-dev --optimize-autoloader'

We'll set the amount of old releases we want to keep as a backup to 3:

set :keep_releases, 3

Finally, set the permission method needed for your application to be able to access certain files and directories. This will depend on the type of hosting you're using, but for my Ubuntu server I had to set it like this:

set :permission_method, :acl
set :file_permissions_users, ["YOUR_SERVER_USER"]
set :file_permissions_paths, ["var", "public/media/cache", "var/uploads"]

In the File Permissions repository you'll find the other possible methods. If your website is running on some kind of shared hosting environment you'll probably need to set is to chown or chmod. If you're getting any strange messages concerning rights, try experimenting with this value first.

Great! Our deploy file is ready and should look something like this:

lock "~> 3.14.0"

set :application, "my-test-project"
set :repo_url, "git@github.com:NAME/YOUR_REPO.git"
set :branch, "master"
set :deploy_to, "/var/www/checkout"
set :linked_files, [".env.local"]
set :linked_dirs, ["var/log", "public/media/cache", "var/uploads"]
set :composer_install_flags, '--no-dev --optimize-autoloader'
set :keep_releases, 2
set :permission_method, :acl
set :file_permissions_users, ["YOUR_SERVER_USER"]
set :file_permissions_paths, ["var", "public/media/cache", "var/uploads"]

We're almost ready for a test-run. We now need to define the environments our website will be deployed to. Since we only have a production environment, we create a config/deploy/production.rb file containing this single line:

server "SERVER_IP_OR_DOMAIN", user: "YOUR_SSH_USERNAME", roles: %w{app db web}

This is the line that defines how Capistrano can connect to the server. Since I don't need a password because I can access my server using my SSH key, all I need to configure is the IP address and the username.

I won't go into detail on the roles. In a nutshell, these can be used for more advanced setups. These roles make it possible to connect to different servers such database servers and perform specific tasks for each server.

On a side note, if you have another environment like a testing or staging environment, you can simply create another deploy file called config/deploy/staging.rb containing those server credentials. A cool thing is that these environment files can overwrite all default setting from our config/deploy.rb. For example, for a testing environment your file could look something like this:

server "TEST_SERVER_IP_OR_DOMAIN", user: "TEST_SSH_USERNAME", roles: %w{app db web}
define :branch, "testing"

Now, as you might remember, Capistrano will SSH into the server and download the repository from the server. Since this is most likely a private repository you'll need to make sure your server has access to it. To achieve this, create a new SSH key pair on your server and make sure you leave the passphrase empty:

ssh user@myip
ssh-keygen -f ~/.ssh/github-deploy -t ed25519 -C "user@host"

Copy the contents of ~/.ssh/github-deploy.pub and browse to your GitHub repository settings. In the left menu click on "Deploy Keys" and add your newly created public key in there. Don't allow write access.

That should be it! Let's try this thing out:

cap production deploy

You'll see a lot of things are happening but at a certain point an error will occur. The problem is that Capistrano can't find an .env.local file in the shared directory, so it can't create a symlink to it. This is something important to know. Capistrano will always create directories if they don't exist but shared files are something you'll always have to create manually.

So SSH into your server again, create the file in checkout/shared, and run cap production deploy again. If all went well you shouldn't run into any more mistakes, and you should have a working checkout/current directory pointing to the release you just deployed 🎉.

Now all that's left is to define your document root. For my Apache server it's a matter of editing the right vhost file located in /etc/apache2/sites-available, and make it look something like this:

<VirtualHost *:80>
    # ...
    DocumentRoot /var/www/checkout/current/public

    <Directory /var/www/checkout/current/public>
        # ...
    </Directory>
    
    # ...
</VirtualHost>

As you can see we are using the current/public directory as the document root which is pointing to the newest release. Visit your website and behold the pretty Symfony landing page 😍.

Test it all out again by making any visual change in your project, deploying it using the cap production deploy command and then visiting your website. Never forget to push your changes before running the deploy command, since Capistrano will always pull from your repository.

GitHub Actions

We can now deploy from a local machine. This might be good enough for personal projects but you don't want to allow this when working in a team. Not only would you need to give everyone access to the server, you'd also have no centralized logs of each deploy, which means nobody knows when deploys have taken place or if any errors have occurred.

This is where we can use GitHub Actions. Simply put, GitHub Actions gives you a VM/container/server or whatever you'd like to call it whenever something new is commited. Within that server we can run whatever commands we like.

Let's try with a simple example. Create a .github/workflow/test.yaml:

name: Deploy to production

on:
  push:
    branches:
      - master

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: "Checkout repository"
        uses: actions/checkout@v2
      - name: Echo something
        run: |
          echo "Seems to be working dude!"
      - name: Show all files
        run: |
          ls

It should be pretty easy to interpret this file. When changes are pushed to the master branch, we want to run a "test" job on the latest version of Ubuntu.

The first step contains a "uses" key. These keys allow us to execute certain pre-configured "actions", in this case checking out our repository so having the files available within our container. The other 2 commands will echo something and list everything in the directory.

Btw, files should always be added to .github/workflow in order for GitHub to pick them up automatically.

Add, commit, push and visit your repository on GitHub. Click the "Actions" tab, and there you'll see your first workflow is running (or might already be finished). Click on the title containing your commit message and click the "test" job. There you'll see all steps being executed and logged in real-time.

So how could we deploy our website in the same way we did locally? Easy! We just need to do the exact same thing as we do on our local machine. We'll need to install Ruby on the Ubuntu server and then execute the same commands:

gem install bundler
bundle install
cap production deploy

Let's do this! Create a .github/workfloy/deploy.yaml file:

name: Deploy to production

on:
  push:
    branches:
      - master

jobs:
  deploy:

    runs-on: ubuntu-latest

    steps:
      - name: "Checkout repository"
        uses: actions/checkout@v2
      - name: Set up Ruby 2.6.x
        uses: actions/setup-ruby@v1
        with:
          ruby-version: 2.6.x
      - name: Deploy
        run: |
          gem install bundler
          bundle install
          cap production deploy

As you can see, we use an existing action to install and setup Ruby so that we don't have to it all manually. We then run the three commands needed to make Capistrano do its magic.

Let's try it out! Commit your changes and check you workflow in Github...

ERROR... Why? Well, our server is secured and is only accessible with our local SSH key. Since Capistrano now runs from a completely new machine it has no access and cannot SSH into the production server.

Time to fix this! What we'll need to do is create a new SSH key that has access to our server. That SSH key then needs to be available within each container created that GitHub creates for our repository.

SSH into your server, and create a new key:

ssh-keygen -t ed25519

Name it for instance "github-deploy", and make sure you don't add a passphrase. You now have a pair of keys, a public key called github-deploy.pub and a private key called github-deploy. Add the contents of the public key to the server's ~/.ssh/authorized-keys file.

So now, every system using the private key is allowed to have access to your server.

Copy the contents of the private key. Go to your GitHub repository settings and click "Secrets" in the left menu. Click the "New repository secret" and paste the contents in the textarea. Name it SSH_ACCESS_KEY. Finally, add an extra step to the deploy file:

name: Deploy to production

on:
  push:
    branches:
      - master

jobs:
  deploy:

    runs-on: ubuntu-latest

    steps:
      - name: "Checkout repository"
        uses: actions/checkout@v2
      - name: Add SSH key
        run: |
          mkdir ~/.ssh
          echo "${{ secrets.SSH_ACCESS_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          eval "$(ssh-agent -s)"
          ssh-add ~/.ssh/id_rsa
      - name: Set up Ruby 2.6.x
        uses: actions/setup-ruby@v1
        with:
          ruby-version: 2.6.x
      - name: Deploy
        run: |
          gem install bundler
          bundle install
          cap production deploy

This will add and enable the SSH key in the container. The reason we do it like that is simply security. Each secret added to your repository will in no way ever be visible for anyone, even for the user that created it. You won't see it in the workflow logs, and you won't see it when you try to update it.

That should be it! Commit your changes and check out your workflow. If everything went well you should now successfully see your project being deployed using GitHub. From now on, every time we commit to our master branch we don't have to do any manual work to put our changes online!

Database migrations and building assets

Capistrano's deploy flow consists of a list of tasks that are run synchronously, the Symfony plugin hooks into that flow but you can hook into it as well. A great example is running your database migrations.

Create an entity and a migration and add this to the bottom of deploy.rb:

before "symfony:cache:warmup", "symfony:migrate"

namespace :symfony do
  task :migrate do
    on roles(:app) do
      execute "cd '#{release_path}' && php bin/console doctrine:migrations:migrate --no-interaction"
    end
  end
end

That's it! We now tell Capistrano to run our migrations before the symfony:cache:warmup action.

It's a wrap!

So this is it! Not only did you learn about Atomic Deployment, you also took your first steps into Continuous Integration and Continuous Delivery (CI/CD).

Once you get a good understanding on how GitHub Actions work you'll discover that there are tons of possibilities for automating many types of workflows. A very common example is running your tests before each deploy so that it gets canceled if there are any failed tests. Might be something to write about next year 😄.

Did you notice you didn't have to accept any ugly cookie policy popup? That's because I got rid of Google Analytics and moved to Fathom, a simplistic, privacy-focused (and amazing) analytics tool !