Deploy a React app to Heroku with Docker

Build, Dockerise, then Deploy a React app on an Express backend

Looi Yih Foo
9 min readDec 4, 2020

Containerizing with Docker is now common part of app deployment. This approach eliminates missing dependencies and aids in maintenance especially when an app consists of many microservices.

This guide aims to get your started with your first Docker deployment. We’ll create a Docker image of a React + Express app then deploy it on Heroku. The console commands below take place in a Windows 10 environment.

Pre-requisites:

  1. Node.js (Download site)
  2. Git (Download site, First-time setup)
  3. heroku-cli (Download site)
  4. A Heroku account (Create a free account here)
  5. Docker Desktop (Download site). For deployment using Container Registry.

Example code to deploy using Container Registry (link)
Example code to deploy using heroku.yml (link)

Step 1: Create the React + Express app

The resulting app will have this structure:

root folder 
|- .git
|- .gitignore
|- web
|- node_modules
|- server.js
|- package.json
|- package-lock.json
|- .env
|- .gitignore
|- client
|- node_modules
|- package.json
|- package-lock.json
|- public
|- src
|- .gitignore
|- readme.md

Create a new folder. Inside it, create another folder named “web”. The “web” folder becomes a process on deployment. By default, Heroku will look for the “ web” process to get a website’s front end. Using a different name for this folder will break the app because Heroku will start the Express server but not the React app.

Open a console in a the “web” folder then run the following commands:

  1. npm init”. This sets up a package.json file. When prompted for the “entry point”, give a descriptive name for the Express JS file. I went with “server.js”.
  2. npm install” the following backend dependencies: express, dotenv, body-parser, and path.
    What each dependency does:
    - express forms the backend of “web
    - dotenv allows using a .env file to specify a PORT number for the React app to listen to, instead of using Heroku’s default PORT.
    - body-parser allows Express to parse JSON
    - path will be used to set a route to React’s index.html
  3. npx create-react-app client”. This creates a React app in a new folder named “client”.
  4. Move the console to the “client” folder (e.g. cd client) then run npm install http-proxy -middleware.
    This dependency routes all calls to Express through a pre-set proxy. Attempting to proxy using package.json by adding “proxy”: “http://localhost:5000" would cause an “Invalid Host Header” error or simply prevent the React app from starting.

Now open a code editor. The node_modules folder in /web and /web/client are quite large. We need to prevent these from being committed to Git. In the root folder, create a .gitignore file and add the following lines:

/web/node_modules/web/client/node_modules

Under “scripts” in /web/package.json, add a “start” and “heroku-postbuild” script. Upon deployment, the “heroku-postbuild” script installs the dependencies of the Express and React parts of this app and creates a production-optimized build of the React app. It should look like this once you are done:

“scripts”: {
“start”: “node server.js”,
"heroku-postbuild": "npm install && cd client && npm install && npm run build",
“test”: “echo \”Error: no test specified\” && exit 1"
},

Now create server.js in /web. Insert the following code:

const express = require('express');
const app = express();
const bodyParser = require('body-parser');
const path = require('path');
require('dotenv').config(); //Required to access .env files
// Use bodyParser to parse JSON
app.use(bodyParser.json());
//URL calls paths must be before paths below to default html page. Otherwise, it will not work
app.get("/server/testResp", testResp)
async function testResp (req, res) {
console.log('Request for data received by Express backend');
res.status(200).json("String sent by Express backend");
}
if(process.env.NODE_ENV != "production"){
app.get('/server/', async function(req, res){
console.log('Main page loading...');
res.sendFile(__dirname + '/client/public/index.html');
});
//__dirname returns the directory that the currently executing script is in
//Thus, the resulting path is: ./root/web/index.html
//Ref: https://stackoverflow.com/questions/25463423/res-sendfile-absolute-path
app.use(express.static(path.join(__dirname, 'client')));
//Put this last among all routes. Otherwise, it will return index.html to all fetch requests and trip up CORS. They interrupt each other
// For any request that doesn't match, this sends the index.html file from the client. This is used for all of our React code.
app.get('*', (req, res) =>{
res.sendFile(path.join(__dirname+'/client/public/index.html'));
})
} else if(process.env.NODE_ENV == "production"){
app.get('/server/', async function(req, res){
console.log('Main page loading...');
res.sendFile(__dirname + '/client/build/index.html');
});

app.use(express.static(path.join(__dirname, 'client/build')));
//Put this last among all routes. Otherwise, it will return HTML to all fetch requests and trip up CORS. They interrupt each other
// For any request that doesn't match, this sends the index.html file from the client. This is used for all of our React code.
//Eliminates need to set redirect in package.json at start script with concurrently
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname+'/client/build/index.html'));
})
}
//App will run on process.env.PORT by default. Must specify or Heroku uses its default port
//It runs on port 4000 only if process.env.PORT is not defined
app.listen(process.env.PORT || 4000, function (){
if(process.env.PORT !== undefined){
console.log(`App running on process.env.PORT ${process.env.PORT}`);
} else {
console.log(`App running on PORT 4000`);
}
});

server.js refers to a .env file get the PORT number and NODE_ENV. Create a .env file in /web then add the following:

PORT=5000
NODE_ENV=production

Now, set up the proxy to PORT 5000 for the React app. Inside /web/client/src, create setupProxy.js then insert the following:

const { createProxyMiddleware } = require('http-proxy-middleware')//Use this to create a proxy to the Express server port 5000 instead of specifying proxy in package.json
module.exports = app => {
//Sends proxy request to http://localhost:5000/*
app.use('/server/*', createProxyMiddleware({ target: `http://localhost:5000`, changeOrigin: true }))
};

With this setup, all http request URLs to Express must begin with /server/ followed by the path name (represented by *).

To test this proxy, a fetch() request needs to be set up. Modify the code in /web/client/src/App.js to the following:

import './App.css';function App(props) {
const queryExpress = () => {
fetch("/server/testResp/", {method: "GET"})
.then(async function(response){
const data = await response.json();
console.log(data);
document.getElementById("message").innerHTML = data;
})
.catch(function(error){
console.log("Request failed", error)
})
}
const clearMessage = () => {
document.getElementById("message").innerHTML = "";
}
return(
<div className="App">
<h1>Express GET request example</h1>
<button onClick={() => {queryExpress();}}>Make GET request</button>
<button onClick={() => {clearMessage();}}>Clear message </button>
<div id="message"></div>
</div>
);
}
export default App;

At this point, we can do a test run to see if everything works. Open a console in /web and /web/client and run “npm start” in each. If all goes well, you’ll have a React app running at “localhost:3000” in your browser and the message “App running on process.env.PORT 5000” logged in console. Clicking on the “Make GET request” button should produce this:

Expected output page at http://localhost:3000/

Go ahead with customizing your new React app. Once you’re done, let’s move on to Step 2.

Step 2: Create a Heroku app

Log in to your Heroku account. From the Dashboard, go to “New > Create new app”. I named mine “docker-heroku-0”:

Name app on create-new-app page
Page to create new Heroku app

After choosing an App Name and Region, click “Create app”. Once the app is created, the page moves to the “Deploy” tab of your app’s homepage. Move to the “Settings” tab and note the Heroku git URL. This will be used later to deploy the Docker container.

Location of Heroku git URL in app Settings tab

Step 3: Generate a Docker image then deploy to Heroku

As of October 2020, Heroku supports 2 methods for deployment using Docker (list in Heroku docs):

  1. Using Container Registry
  2. Using heroku.yml

Let’s try out each one to deploy the same app

Note on on adding additional features: Heroku does not support using additional containers to provide features for the “web” container to access on deployment. The current way to do this is with Heroku add-ons. Examples include caching with Redis and data storage with PostgreSQL.

3a: Deploying using Container Registry

This method pushes Docker images built on your local machine to Heroku. To see the exact steps, go to your Heroku app’s page. Under “Deploy > Deployment” , click on “Container Registry”. The steps will appear below:

Location of Container Registry deployment instructions

Let’s see what happens when following these steps. Start by creating a Procfile in the root directory. Insert the following:

web: npm start

In /web, create a “Dockerfile.web”. “web” is the name of the process. Insert the code below. The resulting image runs on an official Nodejs image (Check Docker Hub for versions).

#Start from the pre-existing official Node image
FROM node:13.12.0
#Specifies directory for all subsequent actions in image filesystem (never host's filesystem)
#"/usr/local/bin/" is what Heroku takes as the "root" folder. Files MUST be added here or they cannot be found!
WORKDIR /usr/local/bin/web
#Copies the app's source code into the image's filesystem
COPY . /usr/local/bin/web/
#Installs packages for Express framework. Runs in container root directory.
RUN npm install
#Installs packages for React app
RUN npm install --prefix client
#Builds React app
RUN npm run --prefix client build
#Runs the app when container launches. Same as running in console
CMD ["npm", "start"]

In the same folder, create a .dockerignore file then insert the code below. The Dockerfile has instructions to install the dependencies listed in web/package.json and web/client/package.json so there is no need to package the large node_modules folders.

node_modules
client/node_modules

git add . then git commit-a these new files to your local Git repo.

It is now time to upload to Heroku. Open a console in the root directory then run the following:

  1. git remote add heroku https://git.heroku.com/<herokuAppName>.git
    Adds the Heroku Git URL to your repo’s remote. In my case, <herokuAppName> is “docker-heroku-0”
  2. heroku login
    Starts a prompt to enter your Heroku credentials to log into the CLI
  3. heroku container:login
    Logs you into the Container Registry
  4. heroku container:push --recursive -a <herokuAppName>
    Starts the image build process. “ — recursive” looks in every subfolder of root to find a Dockerfile.xxx. “-a” specifies the name of the Heroku app in your account.
  5. heroku container:release
    Releases the completed image to your Heroku app
  6. heroku ps:scale web=1
    Starts the “web” process
  7. heroku open
    Triggers your Heroku app to open in a browser.

3b: Deploying using heroku.yml

Now for something a little different. Starting from the same React+Express app made in Step 1, create a “heroku.yml” file in the root directory. Insert the following code:

build:
docker:
web: web/Dockerfile

Unlike a typical Heroku deployment, there is no need to declare a Procfile when using “heroku.yml”.

In /web, create a Dockerfile then insert the code below. The resulting container runs on an official Nodejs image (Check Docker Hub for versions). Notice the Dockerfile is not named “Dockerfile.web” unlike when deploying using Container Registry. This is because the path to the Dockerfile is specified in heroku.yml.

#Start from the pre-existing official Node image
FROM node:13.12.0
#Specifies directory for all subsequent actions in image filesystem (never host's filesystem)
#"/usr/local/bin/" is what Heroku takes as the "root" folder. Files MUST be added here or they cannot be found!
WORKDIR /usr/local/bin/web
#Copies the app's source code into the image's filesystem
COPY . /usr/local/bin/web/
#Installs packages for Express framework. Runs in container root directory.
RUN npm install
#Installs packages for React app
RUN npm install --prefix client
#Builds React app
RUN npm run --prefix client build
#Runs the app when container launches. Same as running in console
CMD ["npm", "start"]

Again in /web, create .dockerignore file then insert the code below. The Dockerfile has instructions to install the dependencies listed in web/package.json and web/client/package.json so there is no need to package the large node_modules folder. Files listed in .dockerignore will not be packaged into the created container.

node_modules
client/node_modules

git add . then git commit-a these new files to your local Git repo.

It’s now time to upload to Heroku. Open a console in the root directory then run the following:

  1. git remote add heroku https://git.heroku.com/<herokuAppName>.git
    Adds the Heroku Git URL to your repo’s remote. In my case, <herokuAppName> is “docker-heroku-0”
  2. heroku login
    Prompts you to enter your Heroku credentials to log into the CLI
  3. heroku stack:set container -a <herokuAppName>
    Changes the stack of the app to “container”. In my case, <herokuAppName> is “docker-heroku-0”
  4. git push heroku master
    Pushes the code into your Heroku app. Your code must be on a branch named “master” in the local Git repo.
  5. heroku ps:scale web=1
    Starts the “web” process
  6. heroku open
    Opens your app in the browser.

Final thoughts

I hope you found this guide helpful in learning how to deploy using Docker. Let me know if something does not work in the comments. If you need a reference, the code I used is available here:

Example code to deploy using Container Registry (link)
Example code to deploy using heroku.yml (link)

--

--