In this tutorial, instead of creating and running a Node app locally, you'll to take advantage of the Debian Linux operating system that official Docker Node images are based on. You'll create a portable Node development environment that solves the "But it runs on my machine" problem that constantly trolls developers since containers are created predictably from the execution of Docker images on any platform.
Throughout this tutorial, you'll be working in two realms:
Local Operating System: Using a CLI application, such as Terminal or PowerShell, you'll use a local installation of Docker to build images and run them as containers.
Container Operating System: Using Docker commands, you'll access the base operating system of a running container. Within that context, you'll use a container shell to issue commands to create and run a Node app.
The container operating system runs in isolation from the local operating system. Any files created within the container won't be accessible locally. Any servers running within the container can't listen to requests made from a local web browser. This is not ideal for local development. To overcome these limitations, you'll bridge these two systems by doing the following:
Mount a local folder to the container filesystem: Using this mount point as your container working directory, you'll persist locally any files created within the container and you'll make the container aware of any local changes made to project files.
Allow the host to interact with the container network: By mapping a local port to a container port, any HTTP requests made to the local port will be redirected by Docker to the container port.
To see this Docker-based Node development strategy in action, you'll create a basic Node Express web server. Let's get started!
Removing the Burden of Installing Node
To run a simple "Hello World!" Node app, the typical tutorial asks to:
- Download and install Node
- Download and install Yarn
- To use different versions of Node, uninstall Node and install
nvm
- Install NPM packages globally
Each operating system has its own quirks making the aforementioned installations non-standard. However, access to the Node ecosystem can be standardized using Docker images. The only installation requirement for this tutorial is Docker. If you need to install Docker, choose your operating system from this Docker installation document and follow the steps provided.
Similar to how NPM works, Docker gives us access to a large registry of Docker images called Docker Hub. From Docker Hub, you can pull and run different versions of Node as images. You can then run these images as local processes that don't overlap or conflict with each other. You can simultaneously create a cross-platform project that depends on Node 8 with NPM and another one that depends on Node 11 with Yarn.
Creating the Project Foundation
To start, anywhere in your system, create a node-docker
folder. This is the project directory.
With the goal of running a Node Express server, under the node-docker
project directory, create a server.js
file, populate it as follows and save it:
// server.js
const express = require("express");
const app = express();
const PORT = process.env.PORT || 8080;
app.get("/", (req, res) => {
res.send(`
<h1>Docker + Node</h1>
<span>A match made in the cloud</span>
`);
});
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}...`);
});
A Node project needs a package.json
file and a node_modules
folder. Assuming that Node is not installed in your system, you'll use Docker to create those files following a structured workflow.
Accessing the Container Operating System
You can gain access to the container OS with any of the following methods:
- Using a single but long
docker run
command. - Using a
Dockerfile
combined with a shortdocker run
command. - Using a
Dockerfile
in combination with Docker Compose.
Using a single docker run
command
Execute the following command:
docker run --rm -it --name node-docker \
-v $PWD:/home/app -w /home/app \
-e "PORT=3000" -p 8080:3000 \
-u node node:latest /bin/bash
Let's breakdown this docker run
command to understand how helps you access the container shell:
docker run --rm -it
docker run
creates a new container instance. The --rm
flag automatically stops and removes the container once the container exits. The combined -i
and -t
flags run interactive processes such as a shell. The -i
flag keeps the STDIN (Standard Input) open while the -t
flag lets the process pretend it is a text terminal and pass along signals.
Think of
--rm
as "out of sight, out of mind".Without the
-it
team, you won't see anything on the screen.
docker run --rm -it --name node-docker
The --name
flag assigns a friendly name to the container to easily identify it in logs and tables. For example when you run docker ps
.
docker run --rm -it --name node-docker \
-v $PWD:/home/app -w /home/app
The -v
flag mounts a local folder into a container folder using this mapping as its argument:
<HOST FOLDER RELATIVE PATH>:<CONTAINER FOLDER ABSOLUTE PATH>
An environmental variable can print the current working directory when the command is executed: $PWD
on Mac and Linux and $CD
on Windows. The -w
flag sets the mounting point as the container working directory.
docker run --rm -it --name node-docker \
-v $PWD:/home/app -w /home/app \
-e "PORT=3000" -p 8080:3000
The -e
flag sets an environmental variable PORT
with a value of 3000
. The -p
flag maps a local port 8080
to a container port 3000
to match the environmental variable PORT
that is consumed within server.js
:
const PORT = process.env.PORT || 8080;
docker run --rm -it --name node-docker \
-v $PWD:/home/app -w /home/app \
-e "PORT=3000" -p 8080:3000 \
-u node node:latest /bin/bash
For security and to avoid file permission problems, the -u
flag sets the non-root user node
available in the Node image as the user that runs the container processes. After setting the flags, the image to execute is specified: node:latest
. The last argument is a command to execute inside the container once it's running. /bin/bash
invokes the container shell.
If the image is not present locally, Docker issues
docker pull
in the background to download it from Docker Hub.
Once the command executes, you'll see the container shell prompt:
node@<CONTAINER ID>:/home/app$
Before moving to the next method, exit the container terminal by typing exit
and pressing <ENTER>
.
Using a Dockerfile
The docker run
command from the previous section is made of image build time and container runtime flags and elements:
docker run --rm -it --name node-docker \
-v $PWD:/home/app -w /home/app \
-e "PORT=3000" -p 8080:3000 \
-u node node:latest /bin/bash
Anything related to image build time can be defined as a custom image using a Dockerfile
as follows:
FROM
specifies the container base image:node:latest
WORKDIR
defines-w
USER
defines-u
ENV
defines-e
ENTRYPOINT
specifies to execute/bin/bash
once the container runs
Based on this, under the node-docker
project directory, create a file named Dockerfile
, populate it as follows, and save it:
FROM node:latest
WORKDIR /home/app
USER node
ENV PORT 3000
EXPOSE 3000
ENTRYPOINT /bin/bash
EXPOSE 3000
documents the port to expose at runtime. However, container runtime flags that define container name, port mapping, and volume mounting still need to be specified with docker run
.
The custom image defined within Dockerfile
needs to be built using docker build
before it can be run. In your local terminal, execute:
docker build -t node-docker .
docker build
provides your image the friendly name node-docker
using the -t
flag. This is different than the container name. To verify that the image was created, run docker images
.
With the image built, execute this shorter command to run the server:
docker run --rm -it --name node-docker \
-v $PWD:/home/app -p 8080:3000 \
node-docker
The container shell prompts comes up with the following format:
node@<CONTAINER ID>:/home/app$
Once again, before moving to the next method, exit the container terminal by typing exit
and pressing <ENTER>
.
Using Docker Compose
For Linux, Docker Compose is installed separately.
Based on the Dockerfile
and the shorter docker run
command of the previous section, you can create a Docker Compose YAML file to define your Node development environment as a service:
Dockerfile
:
FROM node:latest
WORKDIR /home/app
USER node
ENV PORT 3000
EXPOSE 3000
ENTRYPOINT /bin/bash
Command
docker run --rm -it --name node-docker \
-v $PWD:/home/app -p 8080:3000 \
node-docker
The only elements left to abstract from the docker run
command are the container name, the volume mounting, and the port mapping.
Under the node-docker
project directory, create a file named docker-compose.yml
, populate it with the following content, and save it:
version: "3"
services:
nod_dev_env:
build: .
container_name: node-docker
ports:
- 8080:3000
volumes:
- ./:/home/app
nod_dev_env
gives the service a name to easily identify itbuild
specifies the path to theDockerfile
container_name
provides a friendly name to the containerports
configures host-to-container port mappingvolumes
defines the mounting point of a local folder into a container folder
To start and run this service, execute the following command:
docker-compose up
up
builds its own images and containers separate from those created by the docker run
and docker build
commands used before. To verify this run:
docker image
# Notice the image named <project-folder>_nod_dev_env
docker ps -a
# Notice the container named <project-folder>_nod_dev_env_<number>
up
created an image and a container but the container shell prompt didn't come up. What happened? up
starts the full service composition defined in docker-compose.yml
. However, it doesn't present interactive output; instead, it only presents static service logs. To get interactive output, you use docker-compose run
instead to run nod_dev_env
individually.
First, to clean the images and containers created by up
, execute the following command in your local terminal:
docker-compose down
Then, execute the following command to run the service:
docker-compose run --rm --service-ports nod_dev_env
The run
command acts like docker run -it
; however, it doesn't map and expose any container ports to the host. In order to use the port mapping configured in the Docker Compose file, you use the --service-ports
flag. The container shell prompt comes up once again with the following format:
node@<CONTAINER ID>:/home/app$
If for any reason the ports specified in the Docker Compose file are already in use, you can use the --publish
, (-p
) flag to manually specify a different port mapping. For example, the following command maps the host port 4000
to the container port 3000
:
docker-compose run --rm -p 4000:3000 nod_dev_env
Installing Dependencies and Running the Server
If you don't have an active container shell, using any of the previous section methods to access it.
In the container shell, initialize the Node project and install dependencies by issuing the following commands (if you prefer, use
npm
):
yarn init -y
yarn add express
yarn add -D nodemon
Verify that package.json
and node_modules
are now present under your local node-docker
project directory.
nodemon
streamlines your development workflow by restarting the server automatically anytime you make changes to source code. To configure nodemon
, update package.json
as follows:
{
// Other properties...
"scripts": {
"start": "nodemon server.js"
}
}
In the container shell, execute yarn start
to run the Node server.
To test the server, visit http://localhost:8080/
in your local browser. Docker intelligently redirects the request from the host port 8080
to the container port 3000
.
To test the connection of local file content and server, open server.js
locally, update the response as follows and save the changes:
// server.js
// package and constant definitions...
app.get("/", (req, res) => {
res.send(`
<h1>Hello From Node Running Inside Docker</h1>
`);
});
// server listening...
Refresh the browser window and observe the new response.
Modifying and Extending the Project
Assuming that Node is not installed in your local system, you can use the local terminal to modify project structure and file content but you can't issue any Node-related commands, such as yarn add
. As the server runs within the container, you are also not able to make server requests to the internal container port 3000
.
In the event that you want to interact with the server within the container or modify the Node project, you need to execute commands against the running container using docker exec
and the running container ID. You don't use docker run
as that command would create a new isolated container.
Getting the running container ID is easy.
- If you already have a container shell open, the container ID is present in the shell prompt:
node@<CONTAINER ID>:/home/app$
- You can also get the container ID programmatically using
docker ps
to filter containers based on name and return theCONTAINER ID
of any match:
docker ps -qf "name=node-docker"
The
-f
flag filters containers by thename=node-docker
condition. The-q
(--quiet
) limits the output to only display the ID of the match, effectively plugging theCONTAINER ID
ofnode-docker
into thedocker exec
command.
Once you have the container ID, you can use docker exec
to:
- Open a new instance of the running container shell:
docker exec -it $(docker ps -qf "name=node-docker") /bin/bash
- Make a server request using the internal port
3000
:
docker exec -it $(docker ps -qf "name=node-docker") curl localhost:3000
- Install or remove dependencies:
docker exec -it $(docker ps -qf "name=node-docker") yarn add body-parser
One you have another active container shell, you can easily run
curl
andyarn add
there instead.
Recap... and Uncovering Little Lies
You've learned how to create an isolated Node development environment through different levels of complexity: by running a single docker run
command, using a Dockerfile
to build and run a custom image, and using Docker Compose to run a container as a Docker service.
Each level requires more file configuration but a shorter command to run the container. This is a worthy trade-off as encapsulating configuration in files makes the environment portable and easier to maintain. Additionally, you learned how to interact with a running container to extend your project.
You may still need to install Node locally for IDEs to provide syntax assistance, or you can use a CLI editor like vim
within the container.
Even so, you can still benefit from this isolated development environment. If you restrict project setup, installation, and runtime steps to be executed within the container, you can standardize those steps for your team as everyone would be executing commands using the same version of Linux. Also, all the cache and hidden files created by Node tools stay within the container and don't pollute your local system. Oh, and you also get yarn
for free!
JetBrains is starting to offer the capability to use Docker images as remote interpreters for Node and Python when running and debugging applications. In the future, we may become entirely free from downloading and installing these tools directly in our systems. Stay tuned to see what the industry provides us to make our developer environments standard and portable.
About Auth0
Auth0 by Okta takes a modern approach to customer identity and enables organizations to provide secure access to any application, for any user. Auth0 is a highly customizable platform that is as simple as development teams want, and as flexible as they need. Safeguarding billions of login transactions each month, Auth0 delivers convenience, privacy, and security so customers can focus on innovation. For more information, visit https://auth0.com.