/ NodeJS

How to deploy a Node.js app with Ansible, Git and pm2

In recent years Node.js has become a great replacement for your old-and-ugly backend language, thanks also to the large number of framework and npm packages available.

Deploying a Node.js app is simple, but not immediate for everyone. To me, it takes about 6h to write a nice and working Ansible Playbook, so I want to share my experience and my code with you.

If you don't know what is Ansible and how to install it, I suggest you to read my other tutorial about deploying NGINX with Ansible.

The flow

What we are going to build is the following deployment flow, very similar to Capistrano:

  1. Clone the git repository in a new folder inside your_path/releases
  2. Install npm dependencies
  3. Update the symlink to the current release
  4. Update pm2 process
  5. Delete old release

Looking at the last point, we will have always only one release folder. In future I'll write an update of this post allowing us to have 3/5 releases together, so we can change our current version just updating the symlink.

The code

The first thing is to create a file named ansible.cfg in our project root and a deploy folder. In the first file we'll tell Ansible which hosts file read and the deploy folder will contain that hosts file and the playbook.

Ansible.cfg

The content is very simple:

[defaults]
inventory = ./deploy/hosts

[ssh_connection]
control_path = %(directory)s/%%h-%%p-%%r

We are saying to read the hosts inside deploy folder and, under ssh_connection we handle long SSH paths, otherwise it could fail.

Hosts

This file is simpler than ansible.cfg. We group our server(s) and we will use them in our Playbook to specify the target of our deploy. We'll put this in our deploy folder.

[prod]
ec2-xxx-xxx-xxx-xxx.eu-west-1.compute.amazonaws.com ansible_user=ubuntu

[dev]
ec2-xxx-xxx-xxx-xxx.eu-west-1.compute.amazonaws.com ansible_user=ubuntu

The ansible_user variable is used to tell Ansible with what user log in the server.

Playbook

Finally the missing piece and the most important: the Playbook. Our file, called deploy.yml and placed in the deploy folder, will start with YAML header, specifying which hosts will be affected and a var containing our project path. If you want to skip the explanation and just get the final code, jump at the end of this section.

---
- hosts: prod
  vars:
    project_path: /var/www/nodejs

In this case, I wrote as path /var/www/nodejs, but you can use whatever you want.

Because of the first deploy, we need to create the path and setting the owner as the ansible_user specified in the hosts file (in my case ubuntu).

$ sudo mkdir /var/www/nodejs
$ sudo chown ubuntu:ubuntu /var/www/nodejs

Now we have to write the tasks, namely the actions to perform.
In the first task we set some fact. Facts are like variable, but unlike them they are saved on the server, so they won't change with every task. This is important because we use a timestamp as variable and setting it as var and not as fact it will change every time.

---
- hosts: prod
  vars:
    project_path: /var/www/nodejs
  tasks:
    - name: Set some variable
      set_fact:
        release_path: "{{ project_path }}/releases/{{ lookup('pipe','date +%Y%m%d%H%M%S') }}"
        current_path: "{{ project_path }}/current"

Now, with the readlink linux command we retrieve the current release folder, in order to delete it after the deploy. The readlink command returns the target full path of a symlink.

tasks:
    ...
    - name: Retrieve current release folder
      command: readlink -f current
      register: current_release_path
      ignore_errors: yes
      args:
        chdir: "{{ project_path }}"

Note three things:

  1. We used the register Ansible module. It will simple store the output of our command in the given variable, in this case current_release_path.
  2. We need ignore_errors: yes because the first deploy hasn't a symlink, so it would fail blocking all the deploy process.
  3. With chdir in the args section, we tell Ansible to run that task in that specific folder. The variable project_path will be automatically resolved as /var/www/nodejs.

Now, with the file module, we create the new release folder, where we'll clone our repo. We also set permissions to 755.

tasks:
    ...
    - name: Create new folder
      file:
        dest={{ release_path }}
        mode=0755
        recurse=yes
        state=directory

Then, as said before, through the git module, we clone our repository. In this example we use Github, but it works as well with BitBucket.

tasks:
    ...
    - name: Clone the repository
      git:
        repo: git@github.com:USERNAME/REPO.git
        dest: "{{ release_path }}"

Of course, now we need to install npm dependencies. You can use the command module, but Ansible has also the npm module to achieve this with more readability.

tasks:
    ...
    - name: Update npm
      npm:
        path={{ release_path }}

We are at 50% of the work. Now we update or create automatically our current symlink in order to reflect our newest release folder. It will be generated in the project path, not in the deploy folder.

tasks:
    ...
    - name: Update symlink
      file:
        src={{ release_path }}
        dest={{ current_path }}
        state=link

The beauty of the file Ansible module is that, just changing the state argument, you can create, update or delete folders, files and symlinks. You can learn more about it here.

We need the last two steps of our flow: updating pm2 and deleting old release folder.

For pm2, we delete the current process and then start a new one. You can of course use reload instead of deleting and starting again, but I use the delete/start flow because, at the first deploy, the process doesn't exists, so with reload you need first to launch manually pm2 start ... on the server in order to make it working. Using delete/start is more scalable.

tasks:
    ...
    - name: Delete old pm2 process
      command: pm2 delete ws-node
      ignore_errors: yes

    - name: Start pm2
      command: pm2 start {{ current_path }}/server.js --name node-app

In the delete task we use again ignore_errors, because in the first deploy the process doesn't exist. Then, in the start action, we set also a name for our process with --name argument, so deleting or retrieving it will be more simple. You can replace server.js with your file.

Note: the restart of pm2 will cause a downtime of ~5 seconds.

The last step, now, is to delete the old release. If you want to keep all your releases don't include this.

tasks:
    ...
    - name: Delete old dir
      shell: rm -rf {{ current_release_path.stdout }}/
      when: current_release_path.stdout != current_path

Looking this code, you could think: why are you using shell instead of file module, which can delete easily files?
The answer is that we want to delete the old release folder ONLY if it's different to /var/www/nodejs/current and the when module, used for the conditions, works only with command or shell.
Let me explain it better: if the symlink doesn't exists, the path of the older release will be /var/www/nodejs/current, which, at the end of the process, will be the newest release. This happens only at the first deploy, but without this check the latest release will be always deleted, so it's important to keep it.

The when module it's really powerful, so I suggest you to read more about it.

Folders tree and complete code

At the end, your project tree, where you wrote the Ansible code, will look like this:

your_project
├ deploy/
| ├ deploy.yml
| └ hosts
└ ansible.cfg

And the remote tree, var/www/nodejs will be this similar:

/var/www/nodejs
├ current -> /var/www/nodejs/releases/xxxxxxxxxx/
└ releases/
| ├ xxxxxxxxxx/
| | ├ .git
| | ├ node_modules
| | ├ server.js
| | └ ... other files

The final code, which you can copy and paste editing the git repository, will be this:

---
- hosts: prod
  vars:
    project_path: /var/www/nodejs
  tasks:
    - name: Set some variable
      set_fact:
        release_path: "{{ project_path }}/releases/{{ lookup('pipe','date +%Y%m%d%H%M%S') }}"
        current_path: "{{ project_path }}/current"
    - name: Retrieve current release folder
      command: readlink -f current
      register: current_release_path
      ignore_errors: yes
      args:
        chdir: "{{ project_path }}"
    - name: Create new folder
      file:
        dest={{ release_path }}
        mode=0755
        recurse=yes
        state=directory
    - name: Clone the repository
      git:
        repo: git@github.com:USERNAME/REPO.git
        dest: "{{ release_path }}"
    - name: Update npm
      npm:
        path={{ release_path }}
    - name: Update symlink
      file:
        src={{ release_path }}
        dest={{ current_path }}
        state=link
    - name: Delete old pm2 process
      command: pm2 delete ws-node
      ignore_errors: yes
    - name: Start pm2
      command: pm2 start {{ current_path }}/server.js --name node-app
    - name: Delete old dir
      shell: rm -rf {{ current_release_path.stdout }}/
      when: current_release_path.stdout != current_path

Deploy command

To launch your deploy, you can run ansible-playbook deploy/deploy.yml, or, like I set in my project, you can write an npm script in your package.json (where all dependencies are specified), which allows you to write npm run deploy that is more pretty.

Open your package.json and, in the scripts object, append this:

"deploy": "ansible-playbook deploy/deploy.yml".

Have a nice coding (and deploy)!

Danilo Polani

Danilo Polani

Software Engineer and dreamer in startups. Go, Python, Laravel, Ionic, AngularJS, VueJS, NodeJS, JavaScript, Elasticsearch, Redis.

Read More