web dev & more!

Deploying Node.js Apps with Ansible

Published: June 16, 2022
'Automating' comes from the roots 'auto-' meaning 'self-', and 'mating', meaning 'screwing'.
Automating comes from the roots auto- meaning self-, and mating, meaning screwing. Source

While working on a side project, I discovered that serving the application as static assets from a sub-directory on my web server wasn’t going to work. Thanks to CORS, and the fact my application is built with SvelteKit, the only other option for me was to run a Node.js instance. I wanted to get it set up quickly so that I could prioritize application development, but I also wanted to take this opportunity to clean up my homelab automation repository. What if I need to run another Node.js instance later on? It seemed obvious the right thing to do was to spend a week and half automating something I could have accomplished in an afternoon.

Important note: I only spent a week and half on this because I was missing a semi-colon in a configuration file completely unrelated to all of this. 🙃

Background

I’ve previously written about how I have automated much of my homelab management with Ansible. If you’re unfamiliar with Ansible, and spend a significant amount of time managing multiple computers/servers, do yourself a favor and quit reading this blog post and go start learning Ansible. A great series of video tutorials can be found over at Learn Linux TV.

Unfortunately for me, I learned about Ansible after having already set up much of my homelab. Fortunately for you, I’ve been maintaining a repository since I discovered it. Now, as I need new functionality, I build it in to the repo instead of managing my homelab manually. Should my homelab (or home) ever go up in smoke, it will be significantly easier to set it all up again.

Automation

Enough of the snooze-fest, let’s automate!

Firstly, go open my homelab automation repository at https://github.com/Dilden/Ansible-Proxmox-Automation. Peruse that if you like then go ahead and give it a star while you’re at it.

Next, notice how I’ve incorporated roles. Roles make it simple to partition and reuse functionality. For instance, in the roles directory, I’ve created another directory; nodejs. This lets Ansible know to treat the directory name as a role which can now be used within playbooks. When I want to install Node.js on any new server, I can specify the target within my playbook, and simply call the nodejs role. This makes playbooks easier to read and write. It also simplifies the organization of logic.

Node.js

I’m not going to dig into how to create servers. If you’re interested in that, check out my playbook books/create-containers.yml and my proxmox role. For now, let’s focus on installing Node.js on an existing server. In this example, the server is referred to as nodejs in the inventory and is running Ubuntu 20.04 (Focal Fossa).

Playbook

---
- hosts: nodejs
  roles:
    - nodejs

This is it for the playbook. Seriously. This playbook will target nodejs (from the inventory file) and run the tasks from the nodejs role.

Role

---
- name: Install GPG
  tags: nodejs, install, setup
  apt:
    name: gnupg
    update_cache: yes
    state: present

- name: Install the gpg key for nodejs LTS
  apt_key:
    url: "https://deb.nodesource.com/gpgkey/nodesource.gpg.key"
    state: present

- name: Install the nodejs LTS repos
  apt_repository:
    repo: "deb https://deb.nodesource.com/node_{{ NODEJS_VERSION }}.x {{ ansible_distribution_release }} main"
    state: present
    update_cache: yes

- name: Install NodeJS
  tags: nodesjs, install
  apt:
    name: nodejs
    state: latest

One of the wonderful things about Ansible is that it remains easy to read. Just in case you struggled with it, here’s what the 4 tasks do:

  1. Install “gpg” package
  2. Add the repository key for NodeSource
  3. Install the specified version of Node for the specified distribution. These variables are located at defaults/main.yml within the role.
  4. Install the latest version of Node.js

defaults/main.yml

---
NODEJS_VERSION: "18"
ansible_distribution_release: "focal"

Now, installing Node.js on the server is as simple as running ansible-playbook INSERT_YOUR_PLAYBOOK_NAME_HERE.yml

Deploy the App 🚀

Getting Node.js running on your server is one thing but how do you get your code to that server? It’s a little more complicated.

Playbook

---
- hosts: nodejs
  roles:
    - app

Not this part. This part is simple.

Role

- name: Build app locally
  tags: app, build, deploy
  shell: npm run build
  args:
    chdir: ~/Dev/projects/app/
  delegate_to: 127.0.0.1

- name: Copy build to server
  tags: app, build, deploy
  copy:
    src: ~/Dev/projects/app/build/
    dest: /var/www/html/
    owner: www-data
    group: www-data
    mode: 0644

- name: Copy package-lock.json to server
  tags: app, build, deploy
  copy:
    src: ~/Dev/projects/app/package-lock.json
    dest: /var/www/html/package-lock.json
    owner: www-data
    group: www-data
    mode: 0644

- name: Copy package.json to server
  tags: app, build, deploy
  copy:
    src: ~/Dev/projects/app/package.json
    dest: /var/www/html/package.json
    owner: www-data
    group: www-data
    mode: 0644

- name: Create service file
  tags: app, build, deploy
  template:
    src: files/service
    dest: /etc/systemd/system/nodejs.service
  register: service_conf

- name: Reload systemd daemon
  tags: app, build, deploy, systemd
  systemd:
    daemon_reload: yes
  when: service_conf.changed

- name: Install dependencies from lockfile
  tags: app, build, deploy
  shell: npm ci
  args:
    chdir: /var/www/html/

- name: Start NodeJS service
  tags: app, build, deploy
  service:
    name: nodejs
    state: started
    enabled: yes

Ok so it’s not actually that complicated. But still, let’s break down the 8 tasks:

  1. Firstly, I need a build of the application. This is an npm script specific to SvelteKit. The shell command npm run build is being done in the directory ~/Dev/projects/app/ on my local machine via “delegate_to.”
  2. The entire build directory gets copied to the server.
  3. package-lock.json and package.json are copied to the server.
  4. A systemd service worker file is created using a template specified in files/service within this role. I also register service_conf so I can observe if the file changes.
    See my post on starting systemd services within vagrant machines for more information.
  5. If service_conf changed, reload the systemd deamon.
  6. Use npm ci to install dependencies from the lockfile.
  7. Ensure the nodejs service is running on the server.

files/service

[Unit]
Description=nodejs server

[Service]
ExecStart=/usr/bin/node /var/www/html/index.js
Restart=on-failure
# Output to syslog
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=nodejs-example
Environment=NODE_ENV=production PORT=80

[Install]
WantedBy=multi-user.target

This is the service worker template. It calls node on our application’s index.js. Notice how I’ve specified in the environment to run Node.js on port 80.

End

Now, to deploy the app is as simple as running our Node.js install followed by the app deployment. If you combine them together into one playbook…

---
- hosts: nodejs
  roles:
    - nodejs
    - app

ansible-playbook books/deploy-app.yml (or whatever you named your playbook) and you’re all set!

Archived Comments

These comments have been imported from a previous commenting system, for the sake of posterity. If you left a comment using the old system and would like to have it removed, please get in touch with me using this form.