DEV Community

Cover image for How to Dockerize a Node app and deploy to Heroku
Thiago Pacheco
Thiago Pacheco

Posted on • Edited on

How to Dockerize a Node app and deploy to Heroku

I had previously made a tutorial about how to create a Fullstack app and now I'm going to show how to dockerize this app and deploy it to Heroku.
If you didn't follow the previous tutorial, I recommend you to fork the repo and play around with the app.

You can fork the Fullstack app repo here.

Content

So let's dive into code!

Clone the repo.

Download all the dependencies

$ npm install && cd client && yarn
Enter fullscreen mode Exit fullscreen mode

Production environment

We start with the production environment because we are going to create a multistage Dockerfile, and the development environment can simply copy the production environment adding the necessary extra configuration.

First step: Dockerfile

In the root of your project, create a Dockerfile with the following content:

#Dockerfile
# PROD CONFIG
FROM node as prod

WORKDIR /app

COPY package*.json ./

RUN npm install

WORKDIR /app/client

COPY ./client/package*.json ./

RUN npm install

WORKDIR /app

COPY . .

ENV NODE_ENV=production

CMD [ "npm", "start" ]
Enter fullscreen mode Exit fullscreen mode

The first line, we define which image we are going to use and an alias, in this case, a node image and a prod alias.

WORKDIR: Workdir works as mkdir (Create a folder) and cd /folder (enter into the folder).

COPY and RUN: In these lines 6 and 8, we copy the package.json and package-lock.json into our workdir and install the dependencies.

Lines 10, 12 and 14: Here we change the workdir, repeat the copy and run actions, but this time we install the dependencies for the client (frontend app).

Lines 16 and 18 we go back to the project root dir and copy all the files. We just copy all the files if the previous steps succeeded.

Lines 20 and 22: Here we define the environment as production and run the start script.

2 step: package.json prod config

To run the production config, we need to build the frontend app and we can do that by adding a pre-start script.
Open the package.json file in the root of the project and add the following script:

"prestart": "npm run build --prefix client",
Enter fullscreen mode Exit fullscreen mode

3 step: docker-compose production file

Now we are already able to build the image and run it, and the best way to do it is by using a docker-compose file.
In the root of the project, create a docker-compose-test-prod.yml with the following content:

version: "3.7"

services:

  node-react-starter-db:
    image: mongo
    restart: always
    container_name: node-react-starter-db
    ports:
      - 27017:27017 
    volumes:
      - node-react-starter-db:/data/db
    networks: 
      - node-react-starter-network

  node-react-starter-app:
    image: thisk8brd/node-react-starter-app:prod
    build: 
      context: .
      target: prod
    container_name: node-react-starter-app
    restart: always
    volumes: 
      - .:/app
    ports:
      - "80:5000"
    depends_on:
      - node-react-starter-db
    environment:
      - MONGODB_URI=mongodb://node-react-starter-db/node-react-starter-db
    networks: 
      - node-react-starter-network

volumes: 
  node-react-starter-db:
    name: node-react-starter-db

networks: 
  node-react-starter-network:
    name: node-react-starter-network
Enter fullscreen mode Exit fullscreen mode

As this project uses mongodb, the first service node-react-starter-db runs a mongoDB container using the network and volumes that will be created at the end of the file.

Our app is defined in the second service, in which we define the build file and the target.
context: . means it tries to search for a Dockerfile in the root of the project.
the target tag defines which stage we are going to use, in this case prod.

Create a .dockerignore file in the root of the project with the following content:

.git/
node_modules/
client/node_modules/
npm-debug
docker-compose*.yml
Enter fullscreen mode Exit fullscreen mode

Run production test environment

At this point, we can already test a production environment and we can do it by running the following command in the root of your project:

docker-compose -f docker-compose-test-prod.yml up 
Enter fullscreen mode Exit fullscreen mode

Now if we visit http://localhost we can see the following screen:

Use a HTTP client like Postman or Insomnia to add some products. Make a POST request to http://localhost/api/product with the following JSON content:

{
  "name": "<product name>",
  "description": "<product description here>"
}
Enter fullscreen mode Exit fullscreen mode

Now, you will be able to see a list of products rendered on the screen, like so:

Development environment


Let's update our Dockerfile adding our dev config.

Insert the following code at the end of the Dockerfile:

# DEV CONFIG
FROM prod as dev

EXPOSE 5000 3000

ENV NODE_ENV=development

RUN npm install -g nodemon

RUN npm install --only=dev

CMD [ "npm", "run", "dev" ]
Enter fullscreen mode Exit fullscreen mode

Here we are simply reusing the prod config, overwriting some lines and adding extra config:

Line 25 we reuse the current prod image and create a dev alias.
That is how our multistage is defined.

Line 27 we expose the necessary ports for local development, allowing us to make requests to localhost:3000 and localhost:5000.

Line 29 overwrites the NODE_ENV to use development environment.

Line 31 we install nodemon globally, as we need it for local development.

Line 33 installs all the dev dependencies if any.

Line 35 we overwrite the run script.


At this point, the Dockerfile should look like the following:

# PROD CONFIG
FROM node as prod

WORKDIR /app

COPY package*.json ./

RUN npm install

WORKDIR /app/client

COPY ./client/package*.json ./

RUN npm install

WORKDIR /app

COPY . .

ENV NODE_ENV=production

CMD [ "npm", "start" ]

# DEV CONFIG
FROM prod as dev

EXPOSE 5000 3000

ENV NODE_ENV=development

RUN npm install -g nodemon

RUN npm install --only=dev

CMD [ "npm", "run", "dev" ]
Enter fullscreen mode Exit fullscreen mode

Create a docker-compose file for dev environment

Now we need a docker-compose file to test our development environment, creating a simple mongoDB, network and volumes like we did for the prod environment, but now we simply specify the dev target.

Create a docker-compose.yml file in the root of the project with the following content:

version: "3.7"

services:

  node-react-starter-db:
    image: mongo
    restart: always
    container_name: node-react-starter-db
    ports:
      - 27017:27017 
    volumes:
      - node-react-starter-db:/data/db
    networks: 
      - node-react-starter-network

  node-react-starter-app:
    image: thisk8brd/node-react-starter-app:dev
    build: 
      context: .
      target: dev
    container_name: node-react-starter-app
    restart: always
    volumes: 
      - .:/app
    ports:
      - "5000:5000"
      - "3000:3000"
    depends_on:
      - node-react-starter-db
    environment:
      - MONGODB_URI=mongodb://node-react-starter-db/node-react-starter-db
    networks: 
      - node-react-starter-network

volumes: 
  node-react-starter-db:
    name: node-react-starter-db

networks: 
  node-react-starter-network:
    name: node-react-starter-network
Enter fullscreen mode Exit fullscreen mode

Run development environment

Now we can run the app with the following command in the root of your project:

docker-compose up --build
Enter fullscreen mode Exit fullscreen mode

The first run will take a while because it will rebuild everything, adding the necessary changes.

For the next runs you can simply run without the --build tag and it will be way faster:

docker-compose up
Enter fullscreen mode Exit fullscreen mode

Remember to always add the --build whenever you change between dev or prod test environments.

Now you can visit http://localhost:3000 and see the app running.

You can also make a POST request to http://localhost:5000/api/product with the following JSON content:

{
  "name": "<product name>",
  "description": "<product description here>"
}
Enter fullscreen mode Exit fullscreen mode

Now, you will be able to see a list of products rendered on the screen, like so:

With this development environment, you are able to make any changes to the project and it will reflect in your app with a pretty nice hot reload.

Heroku deployment

Now that we already have our dev and prod images, let's deploy this app to Heroku.

First, let's login:

$ heroku container:login
Enter fullscreen mode Exit fullscreen mode

Now, we create an app

$ heroku create
Enter fullscreen mode Exit fullscreen mode

After that, an app will be created and it will be available in your Heroku account.
You will also receive the name of the app created and its URL.

Visit your heroku account, enter the app you just created and click in configure add-ons.

In this page, search for mLab mongoDB and add it to your app.


You can go back to the terminal and add a tag to the prod image to be able to deploy it to Heroku:

$ docker tag thisk8brd/node-react-starter-app:prod registry.heroku.com/<HEROKU-APP-NAME>/web
Enter fullscreen mode Exit fullscreen mode


Push this image to Heroku registry:

$ docker push registry.heroku.com/<HEROKU-APP-NAME>/web
Enter fullscreen mode Exit fullscreen mode


Now you can release the image with the following command:

$ heroku container:release web
Enter fullscreen mode Exit fullscreen mode

This will start your app, it will be available in a minute and you will be able to open the app:

$ heroku open
Enter fullscreen mode Exit fullscreen mode

Yaaay!

Your app was successfully deployed and it is up and running.

You can check my example live here.

And the source code is available here.

I hope you can find this tutorial useful, and see you in the next one!

Top comments (23)

Collapse
 
miachrist profile image
Mia Christ

Thanks for sharing this detailed guide and honestly, I am a big fan of Heroku when it comes to deploy a containerized application. However, since last November, it has become very frustrating to deploy containers with this PaaS platform.

Yes, Heroku has turned all of its free tier plans into paid ventures. But luckily, someone recommended me Container as a Service product of Back4App. Indeed, I followed this article blog.back4app.com/how-to-deploy-do... to quickly deploy Docker container for free compared to paid options of Heroku. I think you should also include freemium CaaS vendors in your upcoming article to help startups and novice developers.

Collapse
 
pacheco profile image
Thiago Pacheco

Thanks for the feedback Mia!
I love the suggestion, I will definitely take a look and consider it for a next post. This post here is very out of date so I am planning to write a new one and I definitely need a PaaS platform with a free tier.

Let me know if you'd like to see any specific technology or stack and I might consider it for the next post too :)

Collapse
 
shameekagarwal profile image
shameekagarwal • Edited

i cannot understand one thing -
when we run docker on our computers, we have to specify "port mapping" e.g. docker run my-image -p 4000:3000
now we can access it on localhost:4000 (3000 is the docker container's port, 4000 host port)

so why do we need to process.env.PORT on our nodejs app ?
all we need to do is have node listen on any port (lets say 3000), then specify "port mapping" -p $PORT:3000 ($PORT is the port which heroku provides)

am i missing something?

Collapse
 
pacheco profile image
Thiago Pacheco

The objective of having the PORT environment variable in your node application is that you can specify a dynamic port to run your app and that is the best way to make sure that your container and your app are using the same port to communicate with each other. We usually run the docker application through a docker-compose.yaml file, or maybe your application can be managed by an orchestrator which will for sure require a dynamic environment variable to define the ports.
In conclusion, basically having a port defined via an environment variable provides a unified way to declare which port your app should be running.

Collapse
 
shameekagarwal profile image
shameekagarwal • Edited

to make sure that your container and your app are using the same port to communicate with each other.

actually, i wanted to run 3 containers on the same app -

  1. for backend
  2. for frontend
  3. for 'reverse proxy'

i ultimately went on to make 3 separate heroku apps, one for each container, which works absolutely fine.

what if i wanted to run all 3 containers on the same app?

  1. in my local development mode, all 3 containers run on their own port 3000.
  2. i tied my host port 3000 to the reverse proxy container's port 3000
  3. so now from my host, only reverse proxy is accessible, and i only needed one port to run 3 containers.

but in heroku, if i tie one container to the $PORT, what about the other 2 containers?
so i didnt want to run all the containers on the $PORT provided by heroku.

Collapse
 
bronifty profile image
bronifty

Do you have a git repo with your code for the containerized reverse proxy?

Collapse
 
shameekagarwal profile image
shameekagarwal

github.com/shameekagarwal/gql
three different heroku apps frontend (in react, served via nginx), backend (in expressjs), and a reverse proxy (nginx)

Thread Thread
 
bronifty profile image
bronifty • Edited

I don't think Heroku is an effective microservices orchestration tool. You might want to try something like Okteto or Fly.io. (A lot of people like Heroku because it's a free VPS; the ones I mentioned are too.) I've got a working example of the reverse proxy in docker code up on Okteto. Fly works sometimes and other times it's tricky. I didn't bother trying to get it to work there. But here's a link to a recent poast and it also has the git repo and the example. Have a good one.

dev.to/bronifty/nginx-reverse-prox...

Thread Thread
 
shameekagarwal profile image
shameekagarwal

thanks!!
my example does work..but heroku isnt for container orchestration..just deploy containers..

Collapse
 
samshpakov profile image
Sam Shpakov

Why install npm if the installation is written in the dockerfile. For this, docker was created so that you do not need to install anything on the machine, in docker everything should be installed by itself at startup.

Collapse
 
pacheco profile image
Thiago Pacheco

Yes, that is true Sam. But in the end, the result will be the same while doing that in the dev environment because, as we have a volume pointing to the local file system, any installation in the container or in the local system will end up creating the node_modules folder in the local file system anyway.
There are implementations to avoid that, but for simplicity matters of this article, I decided to keep it as is.

Collapse
 
samshpakov profile image
Sam Shpakov

Can you post a link to this implementation?

Collapse
 
rodrigogalvez profile image
Rodrigo Gálvez

Good tutorial. I followed step by step. But I got an error when execute the last step: heroku open. :(

2020-10-30T17:33:26.078663+00:00 app[web.1]: /app/node_modules/mongodb-core/lib/topologies/server.js:431
2020-10-30T17:33:26.078664+00:00 app[web.1]: new MongoNetworkError(
2020-10-30T17:33:26.078665+00:00 app[web.1]: ^
2020-10-30T17:33:26.078665+00:00 app[web.1]:
2020-10-30T17:33:26.078666+00:00 app[web.1]: MongoNetworkError: failed to connect to server [localhost:27017] on first connect [Error: connect ECONNREFUSED 127.0.0.1:27017
2020-10-30T17:33:26.078667+00:00 app[web.1]: at TCPConnectWrap.afterConnect as oncomplete {
2020-10-30T17:33:26.078667+00:00 app[web.1]: name: 'MongoNetworkError',
2020-10-30T17:33:26.078667+00:00 app[web.1]: errorLabels: [Array],
2020-10-30T17:33:26.078668+00:00 app[web.1]: [Symbol(mongoErrorContextSymbol)]: {}
2020-10-30T17:33:26.078668+00:00 app[web.1]: }]
2020-10-30T17:33:26.078670+00:00 app[web.1]: at Pool. (/app/node_modules/mongodb-core/lib/topologies/server.js:431:11)
2020-10-30T17:33:26.078670+00:00 app[web.1]: at Pool.emit (node:events:327:20)
2020-10-30T17:33:26.078670+00:00 app[web.1]: at /app/node_modules/mongodb-core/lib/connection/pool.js:557:14
2020-10-30T17:33:26.078671+00:00 app[web.1]: at /app/node_modules/mongodb-core/lib/connection/connect.js:39:11
2020-10-30T17:33:26.078671+00:00 app[web.1]: at callback (/app/node_modules/mongodb-core/lib/connection/connect.js:261:5)
2020-10-30T17:33:26.078672+00:00 app[web.1]: at Socket. (/app/node_modules/mongodb-core/lib/connection/connect.js:286:7)
2020-10-30T17:33:26.078672+00:00 app[web.1]: at Object.onceWrapper (node:events:434:26)
2020-10-30T17:33:26.078672+00:00 app[web.1]: at Socket.emit (node:events:327:20)
2020-10-30T17:33:26.078673+00:00 app[web.1]: at emitErrorNT (node:internal/streams/destroy:194:8)
2020-10-30T17:33:26.078673+00:00 app[web.1]: at emitErrorCloseNT (node:internal/streams/destroy:159:3)
2020-10-30T17:33:26.078673+00:00 app[web.1]: at processTicksAndRejections (node:internal/process/task_queues:80:21) {
2020-10-30T17:33:26.078674+00:00 app[web.1]: errorLabels: [ 'TransientTransactionError' ],
2020-10-30T17:33:26.078674+00:00 app[web.1]: [Symbol(mongoErrorContextSymbol)]: {}
2020-10-30T17:33:26.078674+00:00 app[web.1]: }

Collapse
 
pacheco profile image
Thiago Pacheco

Hello Rodrigo, I am so sorry for the delay, I think I missed your comment before.

Based on the logs you sent, it seems like you don't have the MONGODB_URI environment variable defined in your application, so the app is trying to connect to a database in localhost:27017 that does not exist.

Collapse
 
lromero8 profile image
lromero8 • Edited

Hi Thiago,

Thank you very much for this amazing tutorial. It took me quite a while but I was able to dockerize my node app with Angular instead of React, although when I open the app it shows Application Error. I checked the logs and it throws "Error R15 (memory quota vastly exceeded)" I was googling and I think is because NodeJS is taking too much space from the dynos. I don't know how to fix this issue, hopefully you could give me some insight. I will really appreciate your help a lot.

Thank you very much.

Luis Romero

Collapse
 
pacheco profile image
Thiago Pacheco

Hi Romero, I am sorry but I think I missed your comment before.
I would need to take a look at how you have defined your app because it seems like you might have a memory leak going on.
We can chat more about it if you want, send me an email or share the repo with me if you are comfortable and I can try to give you a hand with that :)

Collapse
 
easyrun32 profile image
easyrun32 • Edited

ey nice tut idk if things changed but this is what u wanna do at the end

heroku container:release web --app <YOUR_APP_NAME>
Collapse
 
pacheco profile image
Thiago Pacheco

Thank you!

I guess if you are already in the context of your heroku app in your terminal, you don't really need to pass the --app tag.

Collapse
 
easyrun32 profile image
easyrun32

Yeah i tried doing that without the —app tag and it said use -a or - app /: but its ok i got it to work! Thx loads m8

Collapse
 
bronifty profile image
bronifty • Edited

Deploy to Heroku in Container

  • login to heroku
heroku container:login
Enter fullscreen mode Exit fullscreen mode
  • create an app for containers
heroku create
Enter fullscreen mode Exit fullscreen mode
  • check image names
docker image ls
Enter fullscreen mode Exit fullscreen mode
  • tag the image for heroku
docker tag imagename registry.heroku.com/heroku-app-name-created-in-prev-step/web
Enter fullscreen mode Exit fullscreen mode
  • push the tagged image to heroku's registry
docker push registry.heroku.com/heroku-app-name-created-in-penultimate-step/web
Enter fullscreen mode Exit fullscreen mode
  • release code of app
heroku container:release web -a heroku-app-name-created-in-antepenultimate-step
Enter fullscreen mode Exit fullscreen mode
  • open sesame
heroku open
Enter fullscreen mode Exit fullscreen mode
Collapse
 
ansarulsohan profile image
G. M. Ansarul Kabir Sohan

Hi, thanks for sharing the deployment guideline.

I am trying to deploy a dockerized react app in heroku. But after pushing the image, heroku fails to bind port. I've tried extending timeout to 120s, 180s, still no change. Can you suggest me what can I do now?

Collapse
 
achudan profile image
Achudan TS

Thank you savior

Collapse
 
adamsl394 profile image
Adam Lehrer

Thank you for this !! 🙇🏻‍♂️