Skip to article frontmatterSkip to article content

Tutorial Docker Node.js App for Local Development 2

Introduction

When we are developing an application, we need some means to write code and have the application reload or recompile without us having to rebuild the image each time. We need something more practical. For that, we can use volumes so that the changes we do on our local machines are reflected inside the running container automatically.

To understand how we can achieve it, let’s write a Node.js application that logs some messages. If we change the source code on our local files, the application inside the running container will automatically rerun and execute the new, changed code.

For that, one of the things we need is a docker volume so we can sort of mount a local directory as if it is a disk inside the container.

And whatever programming language or framework we are using should also provide some means of detecting changed files and rerun when they do. For Node.js, we can make use of nodemon.

The Node.js app

/**
 * Alias `console.log` to make it shorter to use. Why not‽
 *
 * @type {Console["log"]} log
 */
const log = console.log.bind(console);

(function main() {
  log('Hello!');
})();

package.json

Let’s add nodemon as a dev dependency, and it can always be latest version (for this very simple example) so we use * instead of a specific version.

{
  "name": "nodejs-v2",
  "version": "0.0.1",
  "scripts": {
    "dev": "nodemon --quiet ./src/app.js"
  },
  "license": "ISC",
  "devDependencies": {
    "nodemon": "*"
  }
}

Install de dependencies and run the dev script:

$ npm install

$ npm run dev
Hello!

All good.

Trying the reload in local

Without stopping the dev script (like by hitting kbd:[Ctrl+C]), suppose we update our log line in ``./src/app.js`’s main function:

./src/app.js

(function main() {
-  log('Hello!');
+  log('Hello, World!')
})();

The new output should now show something like this:

Output

Hello, World!

Seems to be working, at least from our local filesystem. We are ready to try our hand at another Dockerfile.

Dockerfile

FROM node:latest

WORKDIR /myapp

COPY ./src ./myapp/src

ENTRYPOINT ["npm", "run", "dev"]

Nothing new here. We basically just run npm run dev from an ENTRYPOINT instruction.

Build and run

Let’s build and run the image as a container:

$ docker build --tag nodejs-v2:latest

$ docker run nodejs-v2:latest

Output

Hello, World!

If we now change ./src/app.js, the changes will not be reflected at all inside the running container and nodemon will not be able to detect any changes. The files we copied with the COPY instruction while building the image will have the contents they had at that moment. The file system inside the running container has no visibility of our new, changed files.

We need to use a Docker volume so that the changes in our local filesystem are reflected inside the running container.

Docker volumes

We don’t need change anything in Dockerfile and/or rebuild the image. The only thing we need to have the local file system and the container to share files live is to use --volume (or -v for the short option) with the correct mount point values.

In our case, we want to mount the root directory of our project as /myapp inside the container, therefore, we can run docker run like this:

λ docker run --rm --volume ./:/myapp --name node_app nodejs-v2:latest

> nodejs-v2@0.0.1 dev
> nodemon --quiet ./src/app.js

Hello, World!

Note that because we now are using nodemon to run the app, the process lingers indefinitely, and the container keeps running until it is stopped on purpose (or if the process crashes and terminates due to some problem or failure).

Suppose we add another log line to ./src/app.js:

app.js with a new log line

(function main() {
  log('Hello, World!');
+ log('It works! They said I was mad but it works!!');
})();

Then, in addition to the existing output lines, we should see two new lines:

Output

Hello, World!
It works! They said I was mad but it works!!

Stopping the container

Maybe due to the way nodemon works, hitting kbd:[Ctrl+C] three times displays the message “got 3 SIGTERM/SIGINTs, forcefully exiting” and returns the prompt to the user:

$ docker run --rm --volume ./:/myapp --name node_app nodejs-v2:latest

> nodejs-v2@0.0.1 dev
> nodemon --quiet ./src/app.js

Hello, World!
It works! They said I was mad but it works!!
^C^C^C
got 3 SIGTERM/SIGINTs, forcefully exiting

Yet, the container is still running. In situations like this we can stop the container with another docker command instead:

$ docker container stop node_app

Files and permissions

If we run the container:

$ docker run --rm --volume ./:/myapp --name node_app nodejs-v2:latest

And shell into it, and create a file from that shell running inside the container, then the file belongs to the root user and the root group:

$ docker exec -it node_app /bin/bash
root@669d48bafe50:/myapp# 1> ./message.txt echo 'A test message.'
root@669d48bafe50:/myapp# ls -l
total 44
-rw-r--r--  1 node node    93 Sep 15 10:45 Dockerfile
-rw-r--r--  1 node node  5520 Sep 15 13:52 README.adoc
-rw-r--r--  1 root root    16 Sep 15 13:56 message.txt
drwxr-xr-x 32 node node  4096 Sep 14 13:56 node_modules
-rw-r--r--  1 node node 12298 Sep 14 13:56 package-lock.json
-rw-r--r--  1 node node   177 Sep 14 13:56 package.json
drwxr-xr-x  2 node node  4096 Sep 14 14:10 src
root@669d48bafe50:/myapp#

And if we now look from the host system shell, the file will be there as well, and also belonging to root:root.

Fortunately, docker run takes the --user option, in the format --user user_id:group_id, which we can dynamically set with the id command on many Unix systems. For example, on my Arch Linux system, my user has the ID 1000 and my group id is also 1000:

$ id --user
1000

$ id --group
1000

So we could run a command similar to this one:

$ docker run \
    --rm \
    --user 1000:1000 \
    --volume ./:/myapp \
    --name node_app \
    nodejs-v2:latest

But on other machines or systems, the user and group ID could be different, so we are probably better off by using subshells to create the <user_id>:<group_id> value. For instance:

$ echo "$(id --user):$(id --group)"
1000:1000

And than use that in our docker run command:

$ docker run \
    --rm \
    --user "$(id --user):$(id --group)" \
    --volume ./:/myapp \
    --name node_app \
    nodejs-v2:latest

A very important thing to note is that now the shell prompt inside the container is not showing as # (indicating the root user), but instead, it displays $ (indicating a normal, non-root user).

If we now shell into the container, create a file and list its permissions, it should belong to the non-root user, but to the node user, in case of the image we are using:

$ docker exec -it node_app /bin/bash
node@3af191745741:/myapp$ 1> ./other-message echo 'Another message'
node@3af191745741:/myapp$ ls -l
total 44
-rw-r--r--  1 node node    93 Sep 15 10:45 Dockerfile
-rw-r--r--  1 node node  7674 Sep 15 14:27 README.adoc
drwxr-xr-x 32 node node  4096 Sep 14 13:56 node_modules
-rw-r--r--  1 node node    16 Sep 15 14:37 other-message
-rw-r--r--  1 node node 12298 Sep 14 13:56 package-lock.json
-rw-r--r--  1 node node   177 Sep 14 13:56 package.json
drwxr-xr-x  2 node node  4096 Sep 14 14:10 src

References