in

An example configuration of a fullstack Node app with PostgreSQL using Docker Compose


Docker installed then run the following command on your terminal:

And go to http://localhost:8080

this repository.

For more tutorials like this, follow me @eagleson_alex<script async src=”https://platform.twitter.com/widgets.js” charset=”utf-8″></script> on Twitter

A video version of this tutorial is also available:

{% youtube Te41e4urFO0 %}

Docker is a tool that allows you package the environment for running your application along with the application itself. You can accomplish this as simply as including a single file called Dockerfile with your project.

It uses a concept it calls containers which are lighter weight (require less resources) than full on virtual machines to create the environment for your application. These containers are designed to be extremely portable which means that you can quickly deploy them anywhere, and also scale up your app quickly by simply deploying more copies of your container.

All you need to do is define the requirements for your environment in the Dockerfile (for example Ubuntu 18, Node.js, etc) and every time your container is started on any machine, it will recreate exactly that environment. So you already know in advance that you will not have any issue with missing dependencies or incorrect versions.

That said, it can be challenging to really demonstrate the need for Docker to those new to the development world who haven’t yet experienced a lot of the problems that it solves.

This tutorial aims to simulate a a couple of realistic scenarios you might encounter in a work environment, and show how Docker helps to solve those issues.

If you follow this tutorial you will have a working application running on your machine and querying a Postgres DB without the need to have either Node.js or Postgres installed. The only tool you will need is Docker.

WSL2 on Windows 11 which is a fantastic experience. It works just as well on Mac and Linux, you simply need to follow the installation instructions for your OS.

I recommend Docker Desktop which will give you a nice GUI for working with Docker, however it is not required. For this tutorial will will be managing Docker entirely through the command line (though I may use Docker Desktop for screenshots to show what is happening).

I also suggest having Node.js installed as well. Technically you can get away without it, but in the first couple of steps we’re going to run the app locally before we get Docker involved. It will also help demonstrate how Docker fixes our versioning issue.

Docker Hub, including the ones we will be using.

Let’s begin by testing the simplest container called hello-world.

http://localhost:8080 to see:

One extra thing we would like to enable for this project is file watching and automatic reloading of the server whenever the file is changed.

The easiest way to do that is a tool called nodemon.

npm install nodemon --save-dev

Then add a start script to your package.json file:

package.json

{
  "name": "server",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "start": "nodemon server.js"
  },
  "author": "me",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.2",
  },
  "devDependencies": {
    "nodemon": "^2.0.15"
  }
}

Run your app with:

Try editing your server.js file when your app is running (change “hello world” to “hello world!!!!” or something) and verify that your Node app reloads and you see the change in your browser when you hit the refresh button (the file watching won’t trigger a browser refresh automatically).

Once that is working continue to the next step!

NVM or just read along. Installing a specific Node version of your machine is not required for this part. In fact it’s exactly the problem we’re going to solve with Docker in the next section.

In Node 15 there is a breaking change in the way that unhandled rejected promises are handled. Before version 15 a Javascript promise that was rejected without a catch would give a warning and keep running, but after v15 of Node an unhandled promise will crash the program.

So it’s possible for use to add some code that will make our server work on versions of Node older than 15, but not work on new versions of Node.

Let’s do that now:

server.js

// @ts-check

const express = require("express");
const app = express();
const port = 8080;

app.get("/", async (req, res) => {
  res.setHeader("Content-Type", "text/html");
  res.status(200);
  res.send("<h1>Hello world</h1>");
});

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`);
});

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("good");
  }, 300);
  reject("bad");
});

myPromise.then(() => {
  console.log("this will never run");
});

The code above creates a new promise that always rejects. It will run (with a warning) on Node.js v14, but will crash on v15 and above. Try running it yourself on v15 and above and you get code: 'ERR_UNHANDLED_REJECTION'.

Now obviously we could just… add a catch block (or remove the code entirely), but we are trying to replicate a scenario where you are working with an older codebase and you may not necessarily have those options available to you.

Let’s say for one reason or another this app must be run on Node v15 to work. Every developer on the team must be prepared to operate in that environment… but our company also has a new app that runs on Node v17! So we need that environment available too.

And while we’re at it, some other tool on version X! I only have version Y on my machine! Who knows what version the other members of my team are running. Or the guy I send the app to for testing.

What do I do!?

Enter Docker.

node.

You’ll notice when you look at supported tags there are a lot of versions. Just like having a certain version on your machine, there’s Docker images for pretty much every version you could want. Of course Node itself needs to be installed on some kind of operating system so that’s usually the other part of the tag.

The default Node image runs on Debian, however one of the most popular versions runs on something called Alpine Linux.

The main reason Alpine is popular is because of its small size, it’s a distro of Linux designed to strip out all but the most necessary parts. This means it will be faster and more cost effective to run and distribute our app on this image (assuming it meets our needs).

For our simple app, it does.

Remember we specifically want an older version of Node (older than v15 so our app runs without crashing) so I am going to choose the image tagged node:14-alpine3.12. That’s Node v14 and Alpine v3.12.

We can pull the image in advance with docker pull node:14-alpine3.12 just like we did with hello-world, but it’s not necessary. By adding it to our Dockerfile Docker will automatically pull it from Docker Hub if it doesn’t find it on our machine.

Let’s create a file called Dockerfile (no extension) in the root of our project next to server.js:

Dockerfile

# select your base image to start with
FROM node:14-alpine3.12

# Create app directory
# this is the location where you will be inside the container
WORKDIR /usr/src/app

# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available ([email protected]+)
# copying packages first helps take advantage of docker layers
COPY package*.json ./

RUN npm install
# If you are building your code for production
# RUN npm ci --only=production

# Bundle app source
COPY . .

# Make this port accessible from outside the container
# Necessary for your browser to send HTTP requests to your Node app
EXPOSE 8080

# Command to run when the container is ready
# Separate arguments as separate values in the array
CMD [ "npm", "run", "start"]

I’ve added a lot of comments to help explain each piece of the Dockerfile. You can learn more about Dockerfiles here, I would highly encourage you to skim through that page to get familiar with the commands that are available.

Before we continue I would like to touch briefly on Docker’s layers & cache because they are very important topics!

list of best practices for Dockerfiles.

http://localhost:3000/ to see:

Docker Node Hello World

Great! You’ve created your first custom Docker image and container with your own app running in it!

So now that you have your environment setup, naturally one of the next things you might want to do is update your app. If you make a change to server.js and save the file, are you going to see those changes when you reload the page?

No you won’t. The app is running based on a copy of server.js inside the container which has no direct relation to the one in your project directory. Is there a way that we can “connect” them somehow?

Of course there is, we need to introduce Docker volumes.

volumes to allow you to persist data between running containers.

You can imagine you might want to have your app save some data, but with the way Docker works your containers are designed to be destroyed and recreated casually.

There are two primary ways to use volumes. You can create one in advance and give it a name. This will save all the volume data by default in the /var/lib/docker/volumes directory (in a Linux environment, it would be somewhere different but equivalent on Windows).

To create a named volume (you don’t need to run this command for this tutorial, it’s simply an example):

docker volume create my-named-volume

Then you would map any directory in your container to that directory on your machine. You can do so by adding the --volume flag to your docker run command like so: --volume my-named-volume:/usr/src/app my-node-app.

That example would map the working directory in your container to the Docker volume on your machine. This does not help us however because we want to synchronize a specific directory (our project directory) with the one in the container so we can edit files in our project and have them update in the container.

We can do this as well.

First we need to stop the existing container (which doesn’t have a volume), remove it, and then run it again with the volume:

docker container stop my-node-app-container

docker container rm my-node-app-container

docker run -p 3000:8080 --name my-node-app-container --volume  ${PWD}:/usr/src/app my-node-app

In most terminals PWD means “print working directory” so it will map the current directory to the /usr/src/app directory inside your container. This will accomplish our goal of syncing the files between our project on our computer and the one in our container.

Since we have already set up file watching and reloading with nodemon earlier in the tutorial, you should now be able to edit server.js in your project directory while the container is running (just edit the hello world text), then refresh your browser to see the changes.

And that’s it! You now have a Dockerized Node app where you can make changes on your machine and see the updates happen live inside your container.

At this point we have mostly completed our introduction to Docker itself. We have completed our implementation of our first “scenario” where we use coded instructions to recreate the environment that our app requires in order to operate.

We now need to address our second common scenario: in order to function our application relies on other services, like a database for example. We could technically add the instruction to install the database in our Dockerfile, but that would not realistically mimic the environment our app would be deployed in.

It’s not guaranteed that our Node app and our database would be hosted on the same server. In fact it’s probably not even likely. Not only that, we don’t want to have to boot up our web server to make edits to our database, and vice-versa. Is there a way that we can still use Docker, but create a separation between multiple services that rely on each other?

Yes we can.

their own words:

Compose is a tool for defining and running multi-container Docker applications. With Compose, you use a YAML file to configure your application’s services. Then, with a single command, you create and start all the services from your configuration.

The process is to define the instructions for each of your services with Dockerfiles, and then use Docker Compose to run all those containers together and facilitate network communications between them.

In this tutorial we are going to connect our Node app to a PostgreSQL database. Before we can connect them of course we need to establish the database container.

PostgreSQL. Of course theres also images for MySQL, Mongo, Redis, etc, etc. There’s no reason you couldn’t substitute your favourite out if you want (though if you’re still new to Docker I’d suggest you stick with the tutorial for now).

We search Docker Hub for the official postgres image. We don’t need anything beyond the bare minimum so once again we’ll choose the version running on Alpine. Image postgres:14.1-alpine.

Unlike our Node image, we don’t need to copy any files or run any installation scripts, so we don’t actually need a Dockerfile for our PostgreSQL installation. There are some configurations that we do need (like password and ports for example) but we can manage those with our upcoming docker-compose.yml file.

So aside from deciding which image you are going to use, there is really nothing else we need to do before we create our config file.

random name generator on the “whimsical” setting)

Next we update our Node server to query these values. In addition to doing that, we are going to use express.static to serve up an entire directory rather than just sending HTML as sa string. This will allow us to serve an HTML file along with some CSS and Javascript as well, to create a full-fledged frontend.

Comments are added to explain all the new pieces:

server.js

// Import the postgres client
const { Client } = require("pg");
const express = require("express");
const app = express();
const port = 8080;

// Connect to our postgres database
// These values like `root` and `postgres` will be
// defined in our `docker-compose-yml` file
const client = new Client({
  password: "root",
  user: "root",
  host: "postgres",
});


// Serves a folder called `public` that we will create
app.use(express.static("public"));

// When a GET request is made to /employees
// Our app will return an array with a list of all
// employees including name and title
// this data is defined in our `database-seed.sql` file
app.get("/employees", async (req, res) => {
  const results = await client
    .query("SELECT * FROM employees")
    .then((payload) => {
      return payload.rows;
    })
    .catch(() => {
      throw new Error("Query failed");
    });
  res.setHeader("Content-Type", "application/json");
  res.status(200);
  res.send(JSON.stringify(results));
});

// Our app must connect to the database before it starts, so
// we wrap this in an IIFE (Google it) so that we can wait
// asynchronously for the database connection to establish before listening
(async () => {
  await client.connect();

  app.listen(port, () => {
    console.log(`Example app listening at http://localhost:${port}`);
  });
})();

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("foo");
  }, 300);
  reject("oops");
});

myPromise.then(() => {
  console.log("hello");
});

In the above code update you can see that we are serving up a directory called public that we have not created yet. That directory will contain an index.html file to act as the nice looking frontend for our app.

HTML templates for the employee cards.

public/styles.css

body {
  padding: 12px;
  display: flex;
  flex-direction: row;
  column-gap: 24px;
}

.card {
  box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2);
  transition: 0.3s;
  border-radius: 5px;
  transition: 0.3s;
}

.card:hover {
  transform: scale(1.03);
}

.container {
  padding: 0 12px;
}

img {
  border-radius: 5px 5px 0 0;
}

Above in styles.css is some simple CSS to give a clean look to the employee card templates, and flex them out in a row across the page.

public/script.js

fetch("/employees")
  .then((response) => response.json())
  .then((data) => {
    console.log(data);
    data.forEach((employee) => {
      const cardTemplate = document.querySelector('template');
      const card = cardTemplate.content.cloneNode(true);
      card.querySelector('h4').innerText = employee.name;
      card.querySelector('p').innerText = employee.title;
      document.body.appendChild(card);
    });
  });

When our app is loaded it will load script.js which will use the browser fetch API to query the /employees route on our Node server and get the employee information from out PostgreSQL database.

Once it is returned it will iterate through each employee and clone the HTML template that we defined in index.html to make a custom employee card with that employee’s name and title.

Phew! Now that we have our app established and ready to read from the database, we are finally ready to connect our Node container and our PostgreSQL container together with Docker Compose.

here, and for more details than you can ever handle about the compose file spec see here.

We’re going to be creating a simple docker-compose.yml file to link our Node app with our PostgreSQL database. Let’s jump right in and create the file in our project root directory. I’ll use lots of comments to explain everything:

docker-compose.yml

version: '3.8'
services:
  # These are the configurations for our Node app
  # When Docker Compose starts this container it will automatically
  # use the Dockerfile in the directory to configure it
  app:
    build: .
    depends_on:
      # Our app does not work without our database
      # so this ensures our database is loaded first
      - postgres
    ports:
      - "8080:8080"
    volumes:
      # Maps our current project directory `.` to
      # our working directory in the container
      - ./:/usr/src/app/
      # node_modules workaround for volumes
      # https://stackoverflow.com/a/32785014
      - /usr/src/app/node_modules

  # This is the configuration for our PostgreSQL database container
  # Note the `postgres` name is important, in out Node app when we refer
  # to  `host: "postgres"` that value is mapped on the network to the 
  # address of this container.
  postgres:
    image: postgres:14.1-alpine
    restart: always
    environment:
      # You can set the value of environment variables
      # in your docker-compose.yml file
      # Our Node app will use these to connect
      # to the database
      - POSTGRES_USER=root
      - POSTGRES_PASSWORD=root
      - POSTGRES_DB=root
    ports:
      # Standard port for PostgreSQL databases
      - "5432:5432"
    volumes:
      # When the PostgresSQL container is started it will run any scripts
      # provided in the `docker-entrypoint-initdb.d` directory, this connects
      # our seed file to that directory so that it gets run
      - ./database-seed.sql:/docker-entrypoint-initdb.d/database-seed.sql

So with that docker-compose.yml file in place we are finally ready to run our new and highly improved application “suite” that includes a backend, frontend and database.

From the root directory of the project, all your have to do is type:

docker-compose up --build

(Note the --build flag is used to force Docker to rebuild the images when you run docker-compose up to make sure you capture any new changes. If you simply want to restart existing containers that haven’t changed you can omit it)

Once active you can finally test it out. In our docker-compose.yml config we are mapping post 8080 directly to 8080 so go to http://localhost:8080 to see:

Docker Postgres Fullstack Example

With a cute little hover transition and everything! Congratulations!

If you are using the Docker Desktop GUI application you’ll have a lot of options to stop all the containers at once, or view each one individually. If you are using the command line you can stop both containers with this simple command (run from the project root directory for context):

And there you have it, a fullstack Node.js application with its own SQL database bundled along with it. You can now deploy this literally anywhere that has Docker installed and you know that it will work because you have defined all the parameters of the exact environment it needs to function.

Adding a pgAdmin Panel (Bonus)

Here’s a quick little bonus for those of you who are using PostgreSQL. Adding the pgAdmin panel container to this app setup is a breeze. SImply update your docker-compose.yml config to include the following:

docker-compose.yml

version: '3.8'
services:
    app:
        build: .
        depends_on:
            # Our app does not work without our database
            # so this ensures our database is loaded first
            - postgres
        ports:
            - "8080:8080"
        volumes:
            # Maps our current project directory `.` to
            # our working directory in the container
            - ./:/usr/src/app/

    # This is the configuration for our PostgreSQL database container
    # Note the `postgres` name is important, in out Node app when we refer
    # to  `host: "postgres"` that value is mapped on the network to the 
    # address of this container.
    postgres:
        image: postgres:14.1-alpine
        restart: always
        environment:
            # You can set the value of environment variables
            # in your docker-compose.yml file
            # Our Node app will use these to connect
            # to the database
            - POSTGRES_USER=root
            - POSTGRES_PASSWORD=root
            - POSTGRES_DB=root
        ports:
            # Standard port for PostgreSQL databases
            - "5432:5432"
        volumes:
            # When the PostgresSQL container is started it will run any scripts
            # provided in the `docker-entrypoint-initdb.d` directory, this connects
            # our seed file to that directory so that it gets run
            - ./database-seed.sql:/docker-entrypoint-initdb.d/database-seed.sql

    pgadmin-compose:
        image: dpage/pgadmin4
        environment:
            PGADMIN_DEFAULT_EMAIL: "[email protected]"
            PGADMIN_DEFAULT_PASSWORD: "fakepassword123!"
        ports:
            - "16543:80"
        depends_on:
            - postgres

Notice the pgAdmin panel configuration added at the bottom.

When you run docker-compose up --build now and go to:

http://localhost:16543/

You’ll be greeted with the pgAdmin panel. Enter the PGADMIN_DEFAULT_EMAIL and PGADMIN_DEFAULT_PASSWORD credentials from the docker-compose.yml file to access it.

Once inside click Add New Server.

For General -> Name pick a name. Can be whatever you want.

On the Connection tab values must match the docker-compose.yml file:

  • Host: postgres
  • Username: root
  • Password: root

Now you can navigate from the left bar:

Servers -> whatever-you-want -> Databases -> root -> Schemas -> public -> Tables -> employees

Right click employees an Query Tool:

To see your data.

pgAdmin Employee Data

Docker docs are the best place to start. All the best in your Docker journey.

Please check some of my other learning tutorials. Feel free to leave a comment or question and share with others if you find any of them helpful:

GitHub

View Github


a Go Parallel Processing Library

Go implementation of Fowler’s Money pattern