Deploy a Symfony application with Deployer

Deploy a Symfony application with Deployer

Deployer is a PHP deployment tool that lets you push a git repository and transfer assets to a server. Properly set up, it can save you a lot of time. In this article we will see how we can use it to push our local project to a shared hosting service. 

More about Deployer on deployer.org.

Preparation

Installing Deployer
You can install Deployer with Composer, either globally or specifically for each project. I have encountered problem running some recipes requiring access to remote server’s environment variables when installed globally, so locally is what I’ve come to prefer:

composer require deployer/deployer --dev

Installing Deployer locally for your project means that you will run it from your terminal or console with this syntax

php vendor/bin/dep init

If you are on Windows, Deployer may complain about how paths are translated. If that’s the case, you can use this syntax instead:

php vendor/deployer/deployer/bin/dep init

Initialize a Git repository
Deployer keeps track of changes with the help of Git. For this reason, make sure you have initialized a git repository and tracking it via GitHub or similar service. Read more about it on Github Docs: Adding an existing project to GitHub using the command line - GitHub Docs.

Configure SSH connection
Deploying an app will require an SSH connection. Upon connecting Deployer will prompt for the SSH credentials, and it may do it multiple times. So to avoid having to enter the credentials manually each time for every deployment you can instead generate SSH keys and configure a SSH connection. Read about it in my previous post: Logging on to shared hosting with SSH (andersbjorkland.online).

Set up a recipe

A recipe is a set of instructions for Deployer on how to connect to a remote server and deploy your project. Your specific recipe will depend on:
  • Your project’s specifications.
  • Your development environment’s specifications.
  • The remote server’s specifications.

While many instructions will be similar between the same type of project (Symfony applications for instance), you will have to tweak your recipe depending on how you installed Deployer and the configuration of the remote server. Keeping this in mind, the following assumptions are made:
  • Your setup:
    • PHP 7.4
    • Symfony 5 application
    • Windows (but Mac has similar commands)
    • Git Bash (useful for setting up SSH agent)
    • Deployer is a local dependency
  • The remote server:
    • PHP 7.4
    • Ubuntu 20
    • The public directory is at /www
    • The private directory is at ~ [user home directory] 

Initialize recipe
Now on to starting a new recipe. We let Deployer generate the defaults for us:
php vendor/deployer/deployerbin/dep init

During the initialization process you will be prompted to select what type of application you are deploying. Select Symfony. Then you will be prompted to type in the URL for your git repository. Deployer will detect the URL for you if your project is already connected to a remote repository. Upon completion you will have a new file in the root of your project titled deploy.php

Configure recipe
You will now have a recipe with the most common configurations for deploying a Symfony application set up for you. As of Deployer v.6 it will assume a recipe suitable for a Symfony 3 application. It comes bundled with a recipe for Symfony 4 so we will require that recipe instead. What this actually means is that Deployer will look for Symfony CLI tools in bin/console instead of app/console.

require 'recipe/symfony4.php';

Next up you will set your deployment project name, then set tty and multiplexing to false. Also, the Symfony 4 recipe assumes some soone to be deprecated options for Composer, so we will override it with some updated ones:

set('application', 'homepage');
set('git_tty', false);
set('ssh_multiplexing', false);
set('composer_options', '{{composer_action}} --prefer-dist --no-progress --no-interaction --no-dev --optimize-autoloader');

Having set these Deployer variables, we will move on to the most important option: what host to deploy to. If you have configured a SSH connection in a .ssh/config file, you can reference that connection by assigning that name to the host option:

// Hosts
host(example)
    ->set('deploy_path', '~/{{application}}')
    ->set('http_user', example.com);

You can see that we also set deploy_path and http_user. I’ll be using a shared hosting service like one.com where the http_user will be the same as your domain name.

Having a shared hosting puts some constraints on what you will be able to do. But something you will have to contend with is that you don’t directly dictate what directory should be the public one, as the hosting service will have it specified for you. On one.com the public directory is /www, which also can be referred to as example.com/httpd.www. But your project will have its public directory at ~/example/current/public, which also can be referred to as example.com/httpd.private/example/current/public. There are two strategies to make your public content accessible. The first strategy is to use symbolic links between the two directories. Alas, I have not been able to make this work on one.com. The second strategy is to copy the content of the project’s public directory to the server’s public directory. The second strategy will also mean that you will have to make some configurations to your index.php file, and eventually some other configuration if you are going to be uploading files for public consumption. Add either of these tasks depending on your strategy:

task('symlink:public', function() {    
    run('ln -s {{release_path}}/public/*  /www);
});

task('copy:public', function() {    
    run('cp -R {{release_path}}/public/*  /www && cp -R {{release_path}}/public/.[^.]* /www');
});

Now we can set up some strategies. I will be setting up two different strategies: one for setting up the basic deployment structure for the remote server, which I will call initialize, and another for the full deployment that I call mydeploy. The second strategy is similar to the deploy strategy in the symfony4.php recipe except that it doesn’t include the deploy:writable task, as it conflicts with the remote server configuration that I’ve been working with.

initialize
task('initialize', [        
    'deploy:info',        
    'deploy:prepare',        
    'deploy:lock',        
    'deploy:release',        
    'deploy:update_code',        
    'deploy:shared',        
    'deploy:unlock',        
    'cleanup',
]);

mydeploy
task('mydeploy', [
    'deploy:info',
    'deploy:prepare',
    'deploy:lock',
    'deploy:release',
    'deploy:update_code',
    'deploy:shared',
    'deploy:vendors',
    'deploy:cache:clear',
    'deploy:cache:warmup',
    'deploy:symlink',
    'copy:public',
    'deploy:unlock',
    'cleanup',
]);

The full recipe would look like this:
<?php
namespace Deployer;

require 'recipe/symfony4.php';

/*
 * Run either 'deploy' (Symfony 4 apps) or 'mydeploy' (adjusted for shared host one.com).
 * If running deployer as a project dependency on Windows you may need to run this:
 * php vendor/deployer/deployer/bin/dep deploy
 * instead of php vendor/bin/dep deploy
 */

// Project name
set('application', 'homepage');

// Project repository
set('repository', 'https://github.com/andersbjorkland/project-online');

// [Optional] Allocate tty for git clone. Default value is false.
set('git_tty', false);
set('ssh_multiplexing', false);

set('composer_options', '{{composer_action}} --prefer-dist --no-progress --no-interaction --no-dev --optimize-autoloader');


// Shared files/dirs between deploys 
add('shared_files', []);
add('shared_dirs', []);

// Writable dirs by web server 
add('writable_dirs', []);
set('allow_anonymous_stats', false);

// Hosts
host('anders')
    ->set('deploy_path', '~/{{application}}')
    ->set('http_user', 'andersbjorkland.online')
;
    
// Tasks
task('symlink:public', function() {
    run('ln -s {{release_path}}/public/*  /www &&  ln -s {{release_path}}/public/.[^.]* /www');
});

task('cache:clear', function () {
    run('php {{release_path}}/bin/console cache:clear');
});

/* Is used when symlink from public folder doesn't behave as expected.
 * The downside of using it this way is that it doesn't remove files no longer present in git repo.
 * Assumed public directory is /www
 */
task('copy:public', function() {
    run('cp -R {{release_path}}/public/*  /www && cp -R {{release_path}}/public/.[^.]* /www');
});

/* Uploads built assets from local to remote. Requires rsync.
 * Useful when you use Symfony encore/webpack and remote machine doesn't support npm/yarn.
 */
task('upload:build', function() {
    upload("public/build/", '{{release_path}}/public/build/');
});

task('upload:build', function() {
    upload("public/build/", '{{release_path}}/public/build/');
});

task('init:database', function() {
    run('{{bin/php}} {{bin/console}} doctrine:schema:create');
});

task('echo:options', function() {
    writeln('OPTIONS: {{composer_options}}');
});

task('build', function () {
    run('cd {{release_path}} && build');
});

task('initialize', [
    'deploy:info',
    'deploy:prepare',
    'deploy:lock',
    'deploy:release',
    'deploy:update_code',
    'deploy:shared',
    'deploy:unlock',
    'cleanup',
]);

task('mydeploy', [
    'deploy:info',
    'deploy:prepare',
    'deploy:lock',
    'deploy:release',
    'deploy:update_code',
    'deploy:shared',
    'deploy:vendors',
    'deploy:cache:clear',
    'deploy:cache:warmup',
    'deploy:symlink',
    'copy:public',
    'deploy:unlock',
    'cleanup',
]);

// [Optional] if deploy fails automatically unlock.
after('deploy:failed', 'deploy:unlock');
//after('deploy:unlock', 'copy:public');


// Migrate database before symlink new release.
before('deploy:symlink', 'database:migrate');

Deployment

Having configured your recipe, you can now get ready to deploy. Let’s first configure the deployment structure on the remote server:
php vendor/deployer/deployer/bin/dep initialize

When the tasks are done, SSH into the remote server and configure your .env files under ~/example/shared with correct credentials for database and SMTP. Once that is done, you are ready for a full deployment strategy which you can reuse whenever you want to update your application. So exit the connection to the remote server and run the following command:
php vendor/deployer/deployer/bin/dep mydeploy

first-deploy.gif 1.07 MB


Running this on one.com can take about 2 minutes. For larger Symfony applications I’ve encountered errors when Deployer is either clearing or warming up the cache. This is probably because the memory is running low. One.com offers 128MB RAM and no matter how you instruct PHP to use more memory will override that. You can resort to manually running the cache:clear command, as I’ve been able to do when it first might have failed.

Your application should now be up and running. If you have encountered any problems, feel free to contact me at Twitter: Anders Björkland (@abjorkland) / Twitter.