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.