Deploying nodejs applications with ansible
I’m pretty sure there are better ways to distribute and deploy nodejs applications, but I don’t have the power to change how developers work, I can only give advice and make suggestions. The actual work of a sysadmin is to make things work, even when they are not optimal.
But in order to give advice about something, I need to know how it works, the safe lanes, the shortcuts and, of course, the pitfalls.
My objective for the last weekend was to discover how to deploy a simple nodejs application using ansible.
The starting point
I was aware of what were the tools I was going to use: Node Version Manager, Node Package Manager, Process Manager 2, and NodeJS.
And I had my ansible control node, and my vagrant environment for setting up the lab.
But I lacked an app for testing, and I wanted something simple which I could change to show additional information, like the version number, to easily check if the update process worked as expected.
Creating the app
The best way to start was once more the provider’s (nodejs) getting started guide, I followed the example but made the aforementioned changes.
const http = require('http');
const version = "0.1.0"
const hostname = '0.0.0.0';
const port = 3000;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(`Hello world, this is my ${version} try to js`);
});
server.listen(port, hostname, () => {
console.log(`Server running on http://${hostname}:${port}/`);
});
The execution environment
Installing nvm
I recreated one node from the Vagrant lab, to have a clean environment and proceed with the installation of the tools, the first step was obviously, node version manager.
I googled and found several ansible roles, but, after checking commit dates, issues, etc. I found morgangraphic’s ansible-role-nvm and the good news were it was also on ansible galaxy as morgangraphic.ansible_role_nvm, I installed it on my projects roles folder with:
ansible-galaxy install morgangraphic.ansible_role_nvm -p ./roles
Then I followed the instructions on github about how to use it, and came to a double play playbook:
---
- name: create user
hosts: node1
tasks:
- name: create nodeapp user
user:
name: nodeapp
shell: /bin/bash
comment: nodeapp user
- name: install nvm
hosts: node1
roles:
- role: morgangraphics.ansible_role_nvm
nvm_commands:
- "nvm exec default npm install"
become_user: nodeapp
nvm_install: curl
nvm_profile: /home/nodeapp/.bashrc
It was necessary because roles are executed before tasks, and I wanted nvm installed as the nodeapp user.
Installing pm2
I was tempted to use nvm_commands from the ansible-role-nvm’s variables to install pm2, and it worked when running commands from the target’s shell, but it wasn’t working from ansible playbooks when I tried to use pm2 module. So, as per pm2 module’s suggestion, I used the npm module for installing pm2:
---
- name: install pm2
hosts: all
become_user: nodeapp
environment:
NVM_DIR: /home/nodeapp/.nvm
PATH: /home/nodeapp/.nvm/versions/node/v14.16.1/bin:{{ ansible_env.PATH }}
tasks:
- name: install pm2
npm:
name: pm2
global: yes
It took me several tries to make it work, but the key was setting NVM_DIR and the PATH environment variables, ansible is not loading user’s environment, so I loaded those two variables.
Deploying and running the app
In order to simulate a real environment, I uploaded my test application to github and pushed a tag in order to get .tar.gz package I could download.
Then I downloaded it to the target machine and decompress it on a new directory, linked a current symbolic link to the new folder and launched the application:
---
- name: Install npm app
hosts: node1
become_user: nodeapp
vars:
src_url: https://github.com/juanjo-vlc/nodejs-tests/archive/refs/tags/v{{ version }}.tar.gz
base_dir: /var/www/nodeapps/
web_dir: "{{ base_dir }}/nodejs-tests-{{ version }}"
environment:
NVM_DIR: "{{ ansible_env.HOME }}/.nvm"
PATH: "{{ ansible_env.HOME }}/.nvm/versions/node/v14.16.1/bin:{{ ansible_env.PATH }}"
tasks:
- name: ensure {{ base_dir }} exists
file:
path: "{{ base_dir }}"
state: "directory"
owner: "nodeapp"
group: "nodeapp"
become_user: root
- name: download release
unarchive:
src: "{{ src_url }}"
dest: "{{ base_dir }}"
creates: "{{ web_dir }}"
remote_src: yes
tags: app
notify: restart servers
- name: create link
file:
src: "{{ web_dir }}"
dest: "{{ base_dir }}/current"
state: link
notify: restart servers
handlers:
- name: destroy pm2 app
pm2:
name: nodejs-tests
state: absent
listen: restart servers
- name: start pm2 app
pm2:
name: nodejs-tests
script: "{{ base_dir }}/current/app.js"
chdir: "{{ base_dir }}/current"
listen: restart servers
I did’t use the reload command for PM2 because it would fail when the app is not running, so I destroyed it and created it again.
An update execution looked like:
ansible-playbook deploy-app.yml -e version=0.1.2
I put the link creation between the destruction and recreation of the app, but converting the handler into a task, allows me to rollback to an existing version and relaunch the app. So to do a rollback I ran:
ansible-playbook deploy-app.yml -e version=0.1.1
The package was not downloaded again, but as the link changed back to 0.1.1, the restart handlers were run.
Conclusion
This exercise grew like a snowball, but I managed to make it work. In the process I gained insights about how nodejs apps work, and the tools to keep them running, allow version management, etc.
I’m happy of having worked on it, but there is still a lot to learn and to improve, for example: removing node version numbers, checking if the app was correctly deployed from ansible, and some others.