Dockerizing a Node.js Web Application

  • understand what Docker is and what it does
  • create a simple Dockerfile
  • run a Node.js application using Docker
  • use Continuous Integration to automatically build and test Docker containers

What is Docker, Anyway?

Advantages of Using Docker

  • Rapid application deployment
  • Portability across machines
  • Version control and component reuse
  • Sharing of images/dockerfiles
  • Lightweight footprint and minimal overhead
  • Simplified maintenance

Prerequisites

Create Repository

  1. Go to GitHub and sign up.
  2. Use the New button under Repositories to create a new repository.
  3. In Add .gitignore, select Node.
  4. Create the repository.
  5. Clone the repository to your work machine:
$ git clone YOUR_REPOSITORY_URL
$ cd YOUR_REPOSITORY_NAME

Directory Structure

$ npx express-generator --no-view addressbook
$ cd addressbook
$ npm install

Add a Route

  • Create a new file called routes/persons.js with the following content:
// persons.js

var express = require('express');
var router = express.Router();
var db = require('../database');

router.get("/all", function(req, res) {
db.Person.findAll()
.then( persons => {
res.status(200).send(JSON.stringify(persons));
})
.catch( err => {
res.status(500).send(JSON.stringify(err));
});
});

router.get("/:id", function(req, res) {
db.Person.findByPk(req.params.id)
.then( person => {
res.status(200).send(JSON.stringify(person));
})
.catch( err => {
res.status(500).send(JSON.stringify(err));
});
});

router.put("/", function(req, res) {
db.Person.create({
firstName: req.body.firstName,
lastName: req.body.lastName,
id: req.body.id
})
.then( person => {
res.status(200).send(JSON.stringify(person));
})
.catch( err => {
res.status(500).send(JSON.stringify(err));
});
});

router.delete("/:id", function(req, res) {
db.Person.destroy({
where: {
id: req.params.id
}
})
.then( () => {
res.status(200).send();
})
.catch( err => {
res.status(500).send(JSON.stringify(err));
});
});

module.exports = router;
router.put("/", function(req, res) {
db.Person.create({
firstName: req.body.firstName,
lastName: req.body.lastName,
id: req.body.id
})
.then( person => {
res.status(200).send(JSON.stringify(person));
})
.catch( err => {
res.status(500).send(JSON.stringify(err));
});
});
router.delete("/:id", function(req, res) {
db.Person.destroy({
where: {
id: req.params.id
}
})
.then( () => {
res.status(200).send();
})
.catch( err => {
res.status(500).send(JSON.stringify(err));
});
});
module.exports = router;
  • Get all persons
  • Create a person
  • Get a single person by id
  • Delete a person

Configuring the Database

  1. Install the PostgreSQL node driver and sequelize ORM:
$ npm install --save pg sequelize
  1. Create a file called database.js
// database.js

const Sequelize = require('sequelize');
const sequelize = new Sequelize(process.env.DB_SCHEMA || 'postgres',
process.env.DB_USER || 'postgres',
process.env.DB_PASSWORD || '',
{
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
dialect: 'postgres',
dialectOptions: {
ssl: process.env.DB_SSL == "true"
}
});
const Person = sequelize.define('Person', {
firstName: {
type: Sequelize.STRING,
allowNull: false
},
lastName: {
type: Sequelize.STRING,
allowNull: true
},
});
module.exports = {
sequelize: sequelize,
Person: Person
};
  1. Create a new file for database migration at bin/migrate.js:
// bin/migrate.js

var db = require('../database.js');
db.sequelize.sync();
  1. Install Jest:
$ npm install --save-dev jest
  1. Create a new file called database.test.js:
const db = require('./database');

beforeAll(async () => {
await db.sequelize.sync({ force: true });
});

test('create person', async () => {
expect.assertions(1);
const person = await db.Person.create({
id: 1,
firstName: 'Bobbie',
lastName: 'Draper'
});
expect(person.id).toEqual(1);
});

test('get person', async () => {
expect.assertions(2);
const person = await db.Person.findByPk(1);
expect(person.firstName).toEqual('Bobbie');
expect(person.lastName).toEqual('Draper');
});

test('delete person', async () => {
expect.assertions(1);
await db.Person.destroy({
where: {
id: 1
}
});
const person = await db.Person.findByPk(1);
expect(person).toBeNull();
});

afterAll(async () => {
await db.sequelize.close();
});
  1. Edit package.json and add the following lines in the scripts section:
"scripts": {
"start": "node ./bin/www",
"test": "jest",
"migrate": "node ./bin/migrate.js"
},
  • Create an initial table with sync()
  • Create a person.
  • Get the person.
  • Delete the person.

Start the Application

  1. Create a persons router object near to the index router:
// app.js

. . .

var indexRouter = require('./routes/index');

// add the following line near the indexRouter
var personsRouter = require('./routes/persons');

. . .
  1. Add the persons router object to the application near to the other app.use() lines:
// app.js

. . .

app.use('/', indexRouter);

// add the following line near app.use indexRouter
app.use('/persons', personsRouter);

. . .
  1. To start the application:
$ npm start

Setting Up PM2

$ npm install --save pm2
{
"apps": [
{
"name": "api",
"script": "./bin/www",
"merge_logs": true,
"max_restarts": 20,
"instances": 4,
"max_memory_restart": "200M",
"env": {
"PORT": 3000,
"NODE_ENV": "production"
}
}
]
}
  • Named our application,
  • Defined the file to run,
  • Sets Node.js arguments,
  • Set the environment variables.
"scripts": {
"pm2": "pm2 start processes.json --no-daemon",
"start": "node ./bin/www",
"test": "jest",
"migrate": "node ./bin/migrate.js"
},
$ npm run pm2

Installing Docker

Running Postgres With Docker

$ docker run -it -e "POSTGRES_HOST_AUTH_METHOD=trust" -p 5432:5432 postgres
$ npm run migrate
$ npm run pm2
$ npm run test

> addressbook@0.0.0 test /home/tom/r/dockerizing-test/addressbook
> jest

PASS ./database.test.js
✓ create person (18ms)
✓ get person (6ms)
✓ delete person (7ms)

console.log node_modules/sequelize/lib/sequelize.js:1187
Executing (default): CREATE TABLE IF NOT EXISTS "People" ("id" SERIAL , "firstName" VARCHAR(255) NOT NULL, "lastName" VARCHAR(255), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY ("id"));

console.log node_modules/sequelize/lib/sequelize.js:1187
Executing (default): SELECT i.relname AS name, ix.indisprimary AS primary, ix.indisunique AS unique, ix.indkey AS indkey, array_agg(a.attnum) as column_indexes, array_agg(a.attname) AS column_names, pg_get_indexdef(ix.indexrelid) AS definition FROM pg_class t, pg_class i, pg_index ix, pg_attribute a WHERE t.oid = ix.indrelid AND i.oid = ix.indexrelid AND a.attrelid = t.oid AND t.relkind = 'r' and t.relname = 'People' GROUP BY i.relname, ix.indexrelid, ix.indisprimary, ix.indisunique, ix.indkey ORDER BY i.relname;

console.log node_modules/sequelize/lib/sequelize.js:1187
Executing (default): INSERT INTO "People" ("id","firstName","lastName","createdAt","updatedAt") VALUES ($1,$2,$3,$4,$5) RETURNING *;

console.log node_modules/sequelize/lib/sequelize.js:1187
Executing (default): SELECT "id", "firstName", "lastName", "createdAt", "updatedAt" FROM "People" AS "Person" WHERE "Person"."id" = 1;

console.log node_modules/sequelize/lib/sequelize.js:1187
Executing (default): DELETE FROM "People" WHERE "id" = 1

console.log node_modules/sequelize/lib/sequelize.js:1187
Executing (default): SELECT "id", "firstName", "lastName", "createdAt", "updatedAt" FROM "People" AS "Person" WHERE "Person"."id" = 1;

Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 1.119s
Ran all test suites.

Creating a Dockerfile

$ cd ..
# Dockerfile

FROM node:16.15-alpine3.14
RUN mkdir -p /opt/app
WORKDIR /opt/app
RUN adduser -S app
COPY addressbook/ .
RUN npm install
RUN npm install --save pm2
RUN chown -R app /opt/app
USER app
EXPOSE 3000
CMD [ "npm", "run", "pm2" ]
  • FROM: tells Docker what base image to use as a starting point.
  • RUN: executes commands inside the container.
  • WORKDIR: changes the active directory.
  • USER: changes the active user for the rest of the commands.
  • EXPOSE: tells Docker which ports should be mapped outside the container.
  • CMD: defines the command to run when the container starts.
.git
.gitignore
node_modules/

Bundling and Running the Docker Container

  • Build the container:
$ docker build -t addressbook .
  • Run the container:
$ docker run -it -p 3000:3000 addressbook

Docker Compose

  • Start and stop multiple containers in sequence.
  • Connect containers using a virtual network.
  • Handle persistence of data using Docker Volumes.
  • Set environment variables.
  • Build or download container images as required.
  • Create a file called docker-compose.yml:
# docker-compose.yml

version: "3.9"
services:
postgres:
image: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- '5432:5432'
volumes:
- addressbook-db:/var/lib/postgresql/data

addressbook:
build:
context: .
environment:
DB_SCHEMA: postgres
DB_USER: postgres
DB_PASSWORD: postgres
DB_HOST: postgres
depends_on:
- postgres
ports:
- '3000:3000'

volumes:
addressbook-db:
$ docker ps
$ docker compose run addressbook npm test

Creating dockerizing-nodejs_postgres_1 … done
Creating dockerizing-nodejs_addressbook_run … done
addressbook@0.0.0 test /opt/app
jest
PASS ./database.test.js
✓ create person (21ms)
✓ get person (10ms)
✓ delete person (10ms)
Executing (default): DROP TABLE IF EXISTS "People" CASCADE; Executing (default): DROP TABLE IF EXISTS "People" CASCADE; Executing (default): CREATE TABLE IF NOT EXISTS "People" ("id" SERIAL , "firstName" VARCHAR(255) NOT NULL, "lastName" VARCHAR(255), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY ("id")); Executing (default): SELECT i.relname AS name, ix.indisprimary AS primary, ix.indisunique AS unique, ix.indkey AS indkey, array_agg(a.attnum) as column_indexes, array_agg(a.attname) AS column_names, pg_get_indexdef(ix.indexrelid) AS definition FROM pg_class t, pg_class i, pg_index ix, pg_attribute a WHERE t.oid = ix.indrelid AND i.oid = ix.indexrelid AND a.attrelid = t.oid AND t.relkind = 'r' and t.relname = 'People' GROUP BY i.relname, ix.indexrelid, ix.indisprimary, ix.indisunique, ix.indkey ORDER BY i.relname; Executing (default): INSERT INTO "People" ("id","firstName","lastName","createdAt","updatedAt") VALUES ($1,$2,$3,$4,$5) RETURNING *; Executing (default): SELECT "id", "firstName", "lastName", "createdAt", "updatedAt" FROM "People" AS "Person" WHERE "Person"."id" = 1; Executing (default): DELETE FROM "People" WHERE "id" = 1 Executing (default): SELECT "id", "firstName", "lastName", "createdAt", "updatedAt" FROM "People" AS "Person" WHERE "Person"."id" = 1;
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 2.475s
Ran all test suites.
$ docker compose up -d
$ curl -w "\n" \
-X PUT \
-d "firstName=Bobbie&lastName=Draper" \
localhost:3000/persons
$ curl -w "\n" localhost:3000/persons/all

[
{
"id": 1,
"firstName": "Bobbie",
"lastName": "Draper",
"createdAt": "2020-02-07T23:52:00.448Z",
"updatedAt": "2020-02-07T23:52:00.448Z"
}
]
$ git add -A
$ git commit -m "initial commit"
$ git push origin master

Dockerize the Node application with CI/CD

  1. Go to Docker Hub and get a free account.
  2. Go to Semaphore and sign up.
  3. On the upper right menu, click on Settings
  4. Click on Secrets
  5. Click on New Secret.
  6. Save your Docker Hub username and password, the secret should be called “dockerhub”:
  • Click on the + (plus sign) next to Projects:
  • Select Choose repository and find your repo from the list:
  • Select Continue to Workflow setup and, if prompted, choose I want to configure this project from scratch.
  • Select the Build Docker starter workflow and click on Customize it first:
  • Pipeline: A pipeline has a specific objective, e.g. build. Pipelines are made of blocks that are executed from left to right.
  • Agent: The agent is the virtual machine that powers the pipeline. We have three machine types to choose from. The machine runs an optimized Ubuntu 20.04 image with build tools for many languages.
  • Block: blocks group jobs with a similar purpose. Jobs in a block are executed in parallel and have similar commands and configurations. Once all jobs in a block complete, the next block begins.
  • Job: jobs define the commands that do the work. They inherit their configuration from their parent block.
  • Click on Run the Workflow on the top-right corner.
  • Select the master branch.
  • Click on Start.
  • Click on Edit Workflow on the top-right corner.
  • Click on the Build block.
  • Replace the commands in the box with these:
checkout
echo "${DOCKER_PASSWORD}" | docker login -u "${DOCKER_USERNAME}" --password-stdin
docker pull "${DOCKER_USERNAME}/dockerizing-nodejs-addressbook:latest" || true
docker build --cache-from "${DOCKER_USERNAME}/dockerizing-nodejs-addressbook:latest" -t "${DOCKER_USERNAME}/dockerizing-nodejs-addressbook:latest" .
docker push "${DOCKER_USERNAME}/dockerizing-nodejs-addressbook:latest"
  • Open the Secrets section and check the dockerhub secret:
  • Click on Run the Workflow and Start.
  • checkout: this is a Semaphore built-in command that clones the GitHub repository into the CI environment.
  • docker pull: downloads the image from Docker Hub, if available.
  • docker build: builds the image. If a previous image was pulled, Docker can speed up the build process with layer caching.
  • docker push: pushes the new image to Docker Hub.

Testing the Dockerized Node Application Image

$ git pull origin master
# docker-compose.ci.yml

version: "3.9"
services:
postgres:
image: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- '5432:5432'

addressbook:
image: $DOCKER_USERNAME/dockerizing-nodejs-addressbook:latest
command: "npm run migrate && npm run pm2"
environment:
DB_SCHEMA: postgres
DB_USER: postgres
DB_PASSWORD: postgres
DB_HOST: postgres
depends_on:
- postgres
ports:
- '3000:3000'
FROM registry.semaphoreci.com/node:16
RUN mkdir -p /opt/app
WORKDIR /opt/app
RUN adduser app
COPY addressbook/ .
RUN npm install
RUN npm install --save pm2
RUN chown -R app /opt/app
USER app
EXPOSE 3000
CMD [ "npm", "run", "pm2" ]
$ git pull origin master
$ git add docker-compose.ci.yml Dockerfile
$ git commit -m "add docker compose and Semaphore registry"
$ git push origin master
  • Go back to your Semaphore project, the push you just did should have triggered a new workflow, open it.
  • Click on Edit Workflow.
  • Click on the dotted box: + Add Block to create a new block.
  • Name the block: “Test”
  • Name the job: “Integration Test”
  • Type the following code in the box:
docker-compose run addressbook npm test
  • Open the Prologue section and type the following commands. The prologue is executed before each job in the block:
checkout
echo "${DOCKER_PASSWORD}" | docker login -u "${DOCKER_USERNAME}" --password-stdin
cat docker-compose.ci.yml | envsubst | tee docker-compose.yml
  • Open the Secrets section and check the dockerhub item:
  • Click on Run the Workflow and Start:
$ docker pull YOUR_DOCKER_USERNAME/dockerizing-nodejs-addressbook:latest

Next Steps

  • Add a third container to your setup: the pm2 docs recommend putting a reverse proxy in front of your application. You can add a container with an NGINX image to gain SSL and protect your service. For an example of using a reverse proxy, check our Ruby on Rails tutorial.
  • Add more tests: you can put all kinds of tests into the CI pipeline for better quality control.
  • Add a deployment pipeline: once you decide you want to release your application, you can add more pipelines to your workflow so it automatically deploys to your platform of choice.
  • Self-hosted: run the containers in your server.
  • PaaS: run the containers directly on a Platform as a Service provider such as Heroku.
  • Orchestration: run the application with an orchestrator such as Docker Swarm or Kubernetes.

Conclusion

--

--

--

Supporting developers with insights and tutorials on delivering good software. · https://semaphoreci.com

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Semaphore

Semaphore

Supporting developers with insights and tutorials on delivering good software. · https://semaphoreci.com

More from Medium

Serverless Architecture

The blurring lines between front-end and back-end development

Serverless Web Application with Docker, AWS Fargate and ELB

Start PostgreSQL on WSL Startup