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.
What we are going to build is the following deployment flow, very similar to Capistrano:
- Clone the git repository in a new folder inside
- Update the symlink to the current release
- 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 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.
The content is very simple:
[defaults] inventory = ./deploy/hosts [ssh_connection] control_path = %(directory)s/%%h-%%p-%%r
We are saying to read the
deploy folder and, under
ssh_connection we handle long SSH paths, otherwise it could fail.
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
[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
ansible_user variable is used to tell Ansible with what user log in the server.
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
$ 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: "/releases/" current_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: ""
Note three things:
- We used the
registerAnsible module. It will simple store the output of our
commandin the given variable, in this case
- We need
ignore_errors: yesbecause the first deploy hasn't a symlink, so it would fail blocking all the deploy process.
argssection, we tell Ansible to run that task in that specific folder. The variable
project_pathwill be automatically resolved as
Now, with the
file module, we create the new release folder, where we'll clone our repo. We also set permissions to
tasks: ... - name: Create new folder file: dest= 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: firstname.lastname@example.org:USERNAME/REPO.git dest: ""
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=
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
tasks: ... - name: Update symlink file: src= dest= 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.
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 /server.js --name node-app
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 / 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
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.
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: "/releases/" current_path: "/current" - name: Retrieve current release folder command: readlink -f current register: current_release_path ignore_errors: yes args: chdir: "" - name: Create new folder file: dest= mode=0755 recurse=yes state=directory - name: Clone the repository git: repo: email@example.com:USERNAME/REPO.git dest: "" - name: Update npm npm: path= - name: Update symlink file: src= dest= state=link - name: Delete old pm2 process command: pm2 delete ws-node ignore_errors: yes - name: Start pm2 command: pm2 start /server.js --name node-app - name: Delete old dir shell: rm -rf / when: current_release_path.stdout != current_path
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.
package.json and, in the
scripts object, append this:
"deploy": "ansible-playbook deploy/deploy.yml".
Have a nice coding (and deploy)!