WittCode💻

Connect a React App to Nginx Reverse Proxy for Node Server with Docker Compose

By

Learn how to connect a custom React app to an Nginx reverse proxy for a Node server with Docker Compose. We will create the React app from scratch using Webpack and Express. We will also learn how to handle CORS related issues with Nginx.

Table of Contents 📖

Development vs. Production

In development, Express will serve the React application and Nginx will proxy the requests React makes back to Express. In production, the React application will be bundled into artifacts that will be served by Nginx, as Nginx is better at serving up content. However, the requests will still be proxied from Nginx to Express. For this article, we will be focusing on development mode.

Environment Variable Setup

To begin, lets define the location of our Express server and Nginx reverse proxy in the Docker network using environment variables.

NODE_ENV=development

REVERSE_PROXY_HOST=reverse-proxy-c
REVERSE_PROXY_PORT=5679
REVERSE_PROXY_URL=http://localhost:${REVERSE_PROXY_PORT}

SERVER_HOST=server-c
SERVER_PORT=5678
SERVER_URL=http://localhost:${SERVER_PORT}

We need to specify localhost in the URL variables as the requests will originate from the React application which is outside the Docker network (in the browser). The host environment variables are inside the Docker network.

Project Setup and Webpack Installation

To begin, lets initialize an empty directory as an ES6 npm project by running npm init es6 -y.

npm init es6 -y

Now lets install webpack and webpack-cli as development dependencies from npm. These packages will allow us to turn our React code into JavaScript code that the browser understands.

npm i webpack webpack-cli -D

However, for it to do this, we need to install a Webpack loader, a function that Webpack uses to convert code from one form to another.

npm i babel-loader @babel/preset-env @babel/preset-react -D

Webpack will use the Babel loader will to convert JSX code to code that the browser understands. Now we need to install a plugin for Webpack. A plugin allows us to plug into Webpack's lifecycle.

npm i html-webpack-plugin -D

The html-webpack-plugin will apply our bundled React code to an HTML file. Now lets install nodemon as a development dependency so we can reload our Express server whenever it changes.

npm i nodemon -D

Now lets set the entry point to our application and create a start script to run our Express server.

"main": "./src/server.js",
...
"scripts": {
  "start": "nodemon ."
},

Configuring Webpack

Now lets configure Webpack to use the loaders and plugins we just installed.

const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  mode: 'development',
  target: 'web',
  entry: './src/client/index.jsx',
  output: {
    path: path.resolve(__dirname, './dist'),
    publicPath: '/',
    filename: 'bundle.js',
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/client/index.html',
    }),
    new webpack.EnvironmentPlugin([
      'NODE_ENV',
      'SERVER_HOST',
      'SERVER_PORT',
      'SERVER_URL',
      'REVERSE_PROXY_HOST',
      'REVERSE_PROXY_PORT',
      'REVERSE_PROXY_URL'
    ])
  ],
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              '@babel/preset-env',
              ['@babel/preset-react', {'runtime': 'automatic'}]]
          }
        }
      }
    ]
  },
  resolve: {
    extensions: ['.js', '.jsx']
  }
};
  • mode - The mode to bundle the application in, such as development or production.
  • entry - The entry point to start the bundling process.
  • target - The environment we will be working in. We set it to web as React applications work in the browser.
  • filename - The name of the output code bundle.
  • path - The directory to output the bundle to.
  • publicPath - Tells Webpack where to output its bundled files to. Will be helpful when serving from Express.
  • plugins - Taps into the Webpack lifecycle. Here we telling Webpack to place the bundled JavaScript in the provided HTML file. We also set some environment variables.
  • rules - Tell webpack how to handle a certain module. Here we are telling Webpack to pass any .js or .jsx files (excluding those inside node_modules) through the Babel loader.

Creating a React App

Now lets create our React application. First, lets install the libraries react and react-dom.

npm i react react-dom
  • react - The core library for React.
  • react-dom - Renders the React app in the DOM.

Now lets fill in our index.html file to house the React application.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WittCode</title>
</head>
<body>
    <div id="root"></div>
</body>
</html>

The most important part of this is the div element with the id root. That is what the react-dom package will use to create our React app. Next, create the glue between our DOM and React application.

import React from 'react';
import {createRoot} from 'react-dom/client';
import App from './components/App';

const root = createRoot(document.getElementById('root'));
root.render(<App />);

This code simply renders our React application in the div element with the id root. Now, lets fill in our App.jsx component.

import {useEffect, useState} from 'react';

const App = () => {
  const [cheeses, setCheeses] = useState([]);
  useEffect(() => {
    // Cancel the fetch request
    const controller = new AbortController();
    const {signal} = controller;

    fetch(`${process.env.REVERSE_PROXY_URL}/api/cheeses`, {signal})
      .then(resp => resp.json())
      .then(data => {setCheeses(data);})
      .catch(err => console.error(err));

    return () => {
      controller.abort();
    };
  }, []);

  return cheeses.map((cheese, i) =>
    (<div key={i}>
      <h3>{cheese.name}</h3>
      <h6>{cheese.description}</h6>
    </div>)
  );
};

export default App;
  • Create an App component and some state.
  • Create an Abort Controller to cancel the request if the component unmounts.
  • Make an API call to Nginx which will be proxied to our Express server.
  • Print out a div for each returned data item.

Creating an Express Server

Now lets create our Express server to serve up the React application we just made. To begin, lets install Express from npm.

npm i express

Now lets import Express, create our cheese API, and get the application listening for requests in our main server.js file.

import express from 'express';

const HOST = process.env.SERVER_HOST;
const PORT = process.env.SERVER_PORT;

const app = express();

// Logging middleware
app.use((req, res, next) => {
  console.log('-------------------------------------');
  console.log(`Request: ${req.method} ${req.url}`);
  return next();
});

// /api/cheeses
app.get('/api/cheeses', (req, res) => {
  return res.status(200).json([
    {name: 'Cheddar', description: 'Very tasty.'},
    {name: 'Mozzarella', description: 'Very tasty, Very good OMG'},
    {name: 'Fetta', description: 'Ehhh not the best, but still good'},
    {name: 'Gorgonzola', description: 'Is this how you spell it?'},
  ]);
});

app.listen(PORT, HOST, () => {
  console.log(`Server started listening on ${HOST}:${PORT}`);
});

We also add a useful global logging middleware so we can see the requests coming in from Nginx. Now, to serve our React app from this server, we need to get Webpack and Express to work together. We can do this with the npm package webpack-dev-middleware.

npm i webpack-dev-middleware -D

This package emits files processed by Webpack to a server. We will use it to emit our bundled React application to our Express server which we can then serve to the client. First lets import this middleware into our server.js file along with the webpack library itself and our Webpack configuration.

import webpack from 'webpack'
import webpackDevMiddleware from 'webpack-dev-middleware';
import webpackConfig from '../webpack.config.cjs';

Now lets bind webpack-dev-middleware as application level middleware as we want it to be called each time a request is sent to the server.

const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler, {
  publicPath: webpackConfig.output.publicPath
}));
  • Create a Webpack compiler. When we import webpack directly we receive a function that returns a webpack compiler.
  • Configure the webpack compiler by passing it a Webpack configuration object. This is the object that we export from webpack.config.cjs.
  • Pass the Webpack compiler and options to the webpackDevMiddleware function.
  • Specify the publicPath to be the one in our Webpack configuration. This will be where webpack-dev-middleware serves the bundle from.

Adding Hot Module Replacement to Express

Now, to get our application to reload with changes, we need to use hot module replacement (HMR). HMR detects module changes in an actively running application. This makes the development process more efficient as it only updates what has changed and instantly updates the browser with the modifications. We can enable HMR in a custom Express server by using the npm package webpack-hot-middleware.

npm i webpack-hot-middleware -D

Now lets import the package into our server.js file.

import webpackHotMiddleware from 'webpack-hot-middleware';

Next, we need to add this middleware to the express server as application level middleware.

app.use(webpackHotMiddleware(compiler, {}));

The webpack-hot-module-middleware returns a function that accepts a webpack compiler as the first argument and options as a second argument. Next, inside our Webpack configuration file, we need to add the Webpack HMR plugin to the plugins array.

new webpack.HotModuleReplacementPlugin()

The HMR plugin enables Webpack to perform HMR. Finally, we need to alter our Webpack entry point slightly to contain the Webpack hot middleware.

entry: {
  main: ['webpack-hot-middleware/client?reload=true', './src/client/index.jsx']
},

This code configures our Express server to receive a notification when Webpack builds the client bundle. In other words, when our React application is rebuilt, our Express server will be notified of this, causing it to serve up this new bundle. The ?reload=true is an option that auto-reloads the browser page when Webpack gets stuck.

Creating the Node Dockerfile

Now lets create the Dockerfile to build our Node image.

FROM node:20.11.0-alpine
WORKDIR /server
COPY package*.json .
RUN npm i
CMD ["npm", "start"]
  • Use Node version 20.11.0 as the base image.
  • Set the working directory to /server. This means any RUN, CMD, ENTRYPOINT, COPY, and ADD commands will be executed in this directory.
  • Copy over package.json and package-lock.json.
  • Install dependencies from npm.
  • Start the Node server.

Note how we don't copy over the source code. This is because we will do this later using volumes.

Configuring Nginx as a Reverse Proxy

Now lets configure Nginx to be a reverse proxy. When Nginx proxies a request, it sends the request to the specified server, retrieves the response, and sends it back to the client. To proxy a request to an HTTP server, we use the proxy_pass directive inside a location block.

server {
  listen ${REVERSE_PROXY_PORT};
  server_name ${REVERSE_PROXY_HOST};

  root /etc/nginx;

  location /api {
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_pass http://${SERVER_HOST}:${SERVER_PORT}/api;
  }
}

We can modify headers using the proxy_set_header directive.

  • Host - Specifies the host and port number of the server the request is being sent to. The $host variable here will be the host name from the request line.
  • X-Real-IP - Specifes the IP address of the client. The $remote_addr variable is the client address.
  • proxy_pass - Tells Nginx where to forward the request. We are telling it to send the request to our Node server.

Nginx and CORS

As we are serving our React application from Express but sending requests to Nginx, we will come accross some CORS issues. Cross-origin resource sharing, or CORS, is a mechanism enforced by the browser that uses origins to determine if a website at one origin can request data from a website at a different origin.

INFO: Express is listening on http://localhost:5678 while Nginx is listening on http://localhost:5679. These are different origins!

So how does CORS determine if a cross-origin request is allowed? The answer is HTTP headers. CORS is an HTTP header based mechanism, meaning it is implemented through the use of HTTP headers. Two important headers for CORS are Origin and Access-Control-Allow-Origin. Origin is a request header that indicates the origin that caused the request. Access-Control-Allow-Origin is a response header indicating whether the response can be shared with the origin requesting the data. So, lets add the Access-Control-Allow-Origin header to the response using the add_header directive.

add_header 'Access-Control-Allow-Origin' ${SERVER_URL};
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Headers' 'Authorization,Accept,Origin,DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range';
add_header 'Access-Control-Allow-Methods' 'GET,POST,OPTIONS,PUT,DELETE,PATCH';

INFO: The Origin request HTTP header will be our Express server URL http://localhost:5678.

  • Access-Control-Allow-Origin - Indicates whether the response can be shared with the origin requesting the data..
  • Access-Control-Allow-Credentials - Tells the browser whether the server allows cross-origin requests to include credentials, such as cookies.
  • Access-Control-Allow-Headers - Responds to a preflight request which includes the Access-Control-Request-Headers to indicate which HTTP headers can be used during the request.
  • Access-Control-Allow-Methods - Specifies which methods are allowed when accessing a resource in response to a preflight request.

Creating the Nginx Docker Image

Now lets create the Dockerfile that will be used to build our Nginx image. All we will do in this Dockerfile is set the base image.

FROM nginx:1.25-alpine

We will copy over our configuration into the container using volumes.

Creating a Volume for node_modules

Now lets start working with Docker Compose. To begin, we will create a volume for our node_modules folder. The node_modules folder can be problematic for Docker if it contains packages with binaries specific to certain operating systems. In other words, certain packages will install different files depending on the operating system of the computer. This can cause issues if you are devloping an application with Docker as the Docker container doesn't always use the same OS as the host computer. This is why we create a volume called server-v-node-modules to handle our node_modules.

volumes:
  server-v-node-modules:
    name: "server-v-node-modules"

The top-level volumes declaration lets us configure named volumes. The name property sets a custom name for the volume. Running the command docker compose up for the first time will create the volume. The volume will then be reused when the command is ran subsequently.

Creating our Node Server with Docker Compose

Now lets create our Node server using Docker Compose.

version: "3.9"
services:

  server:
    image: server:1.0.0
    container_name: ${SERVER_HOST}
    build:
      context: ./server
      dockerfile: Dockerfile
    env_file: .env
    restart: always
    ports:
      - ${SERVER_PORT}:${SERVER_PORT}
    volumes:
      - ./server:/server
      - server-v-node-modules:/server/node_modules
  • Create a service called server that will create a Node Docker container from our Dockerfile.
  • Pass the environment variables to the container using our .env file.
  • Restart the container whenever it stops.
  • Map the container port to the host port with the environment variable SERVER_PORT.
  • Use a host volume to bring all our server code into the container. Use a named volume to handle our node_modules.
  • Don't start up the server until the reverse-proxy service, Nginx, is running.

Creating our Nginx Reverse Proxy with Docker Compose

Now lets create our Nginx reverse proxy using Docker Compose.

reverse-proxy:
  image: reverse-proxy:1.0.0
  container_name: ${REVERSE_PROXY_HOST}
  build: 
    context: ./reverse-proxy
    dockerfile: Dockerfile
  env_file: .env
  restart: always
  ports:
    - ${REVERSE_PROXY_PORT}:${REVERSE_PROXY_PORT}
  volumes:
    - ./reverse-proxy/default.conf.template:/etc/nginx/templates/default.conf.template
  depends_on:
    - server
  • Create a service called reverse-proxy that will create a Nginx Docker container from our Dockerfile.
  • Pass the environment variables to the container using our .env file.
  • Restart the container whenever it stops.
  • Map the container port to the host port with the environment variable REVERSE_PROXY_PORT.
  • Use a host volume to bring our Nginx configuration into the container.

INFO: Note that the location we place our Nginx configuration file is important. Any template files placed inside /etc/nginx/templates will receive environment variable substitution through envsubst before being loaded into the main Nginx configuration file nginx.conf.

Running the Application

To run the application, all we need to do is navigate to the top level directory and run docker compose up.

docker compose --env-file .env up

Note how we also supply our environment variable file to Docker Compose. This is so it loads the environment variables into the docker-compose.yaml file. Checking the console output, we can see all our Node server and Nginx reverse-proxy are up and running.

reverse-proxy-c  | 2024/05/03 12:35:02 [notice] 1#1: start worker process 37
reverse-proxy-c  | 2024/05/03 12:35:02 [notice] 1#1: start worker process 38
reverse-proxy-c  | 2024/05/03 12:35:02 [notice] 1#1: start worker process 39
reverse-proxy-c  | 2024/05/03 12:35:02 [notice] 1#1: start worker process 40
reverse-proxy-c  | 2024/05/03 12:35:02 [notice] 1#1: start worker process 41
...
server-c         | [nodemon] starting `node .`
server-c         | Server started listening on server-c:5678

Now simply navigate to localhost:5678 in your browser and see our application running.

Image

INFO: Note the live code updates on both the front and backend as well as the CORS HTTP headers in the developer console!