React App Served by Express in Docker
Running in Docker requires special considerations in the front-end. It should dynamically retrieve the backend end-point. For multiple environments, the same docker image could be used while moving from TST to ACC for example. Build a Docker container and the static assets are fixed.
Back-end service on the same host
A front-end client is running in a docker container will likely need data from a back-end service. On the same domain, requests can simply be pointed to /api/xyz
. No domain host is specified. The domain of the client applies.
Third-party service is only known at runtime
Data from third-party services, not on the same host, are more complicated. It may be possible to inject the third-party host domain at build time. The case below is when the third-party host domain is only known at runtime.
Technology stack
- Docker
- NodeJS
- Express
- React —> static assets (html, css, javascript)
A very simple react application.
// --- app.js
import React from "react";
import { render } from "react-dom";
const App = () => <div>Hello World</div>;
render(<App />, document.getElementById("root"));
Data is retrieved from a TST backend when the component is mounted.
// --- app.js
import React from "react";
import { render } from "react-dom";
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
data: null,
};
}
/**
* When the component mounts,
* fetch the data from the network
*/
async componentDidMount() {
try {
const res = await fetch("/api/data"); // fetch the data
const jsonData = res.status === 200 ? await res.json() : res;
if (jsonData.status === 200) {
this.setState({ data: jsonData });
} else {
throw new Error(`Expected JSON data but received: ${jsonData}`);
}
} catch (error) {
console.error("failed to fetch data", error);
}
}
render() {
return <div>Hello {this.state.data}</div>;
}
}
render(<App />, document.getElementById("root"));
The above code uses the endpoint /api/data
to get the data it needs. If the front-end is running on http://localhost:3000
, then the API request will got to http://localhost:3000/api/data
. (Providing there is no proxy configured).
In our case we would like the API request to go to a different host. The challenging part is that we do not know which host this is until later (at runtime).
In the case of a non-dockerized React application, use the environment variables available at build time. These start with REACT_APP_
. https://create-react-app.dev/docs/adding-custom-environment-variables/
Building the front-end production version results in the apiHost
value being populated correctly. These can be specified in local .env
file where react would read them at build time.
# -- .env
REACT_APP_API_HOST=http://someremotehost.com
Build the application with npm run build
. The remote host is inserted, replacing REACT_APP_API_HOST
where ever it’s used.
// --- app.js
import React from "react";
import { render } from "react-dom";
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
data: null,
};
}
/**
* When the component is mounted,
* fetch the data from the network
*/
async componentDidMount() {
try {
const res = await fetch(
`${process.env.REACT_APP_API_HOST}/api/data`
); // fetch the data
const jsonData = res.status === 200 ? await res.json() : res;
if (jsonData.status === 200) {
this.setState({ data: jsonData });
} else {
throw new Error(`Expected JSON data but received: ${jsonData}`);
}
} catch (error) {
console.error("failed to fetch data", error);
}
}
render() {
return <div>Hello {this.state.data}</div>;
}
}
render(<App />, document.getElementById("root"));
What if the host is not available at build time? Only at runtime?
Running in Docker
Docker adds another layer of complexity. The idea of a Docker image is build it once and run it in various places.
In the case the docker image should be re-used for for TST on ACC and, PRD.
Once the Docker image is built docker build
, it’s not easy to inject variable values.
Running in docker, the front-end uses a server.
That server, A node / express server, can be used to provide the apiHost
to the front-end.
- Starting the Docker container, environment variables are provided at run time
- The container receives and passes the environment variables on to the express server
- The express server passes that variable to the front-end
# run docker image with environment variables
docker run -e "API_HOST=https://mydockerdynamichost.com" -p 3000:3000 mydockerimagename
Slow data retrieval
This is a workable solution. There are downsides though. The front-end has to request the host before any data requests are possible. Network latency means that our application is slower. The next section discusses a solution. Server side rendering.
Server Side Rendering to provide the API HOST
Using server side rendering in the Docker container, the app rendered on the server. It’s also sent to the client. The API HOST variable is provided right away. The need for an additional network request is removed. Initial data should also be loaded before responding to the client.
First ensure the app expects the apiHost
as a prop.
// -- src/app.js
import React, { Component } from "react";
import "./App.css";
class App extends Component {
render() {
return (
<div className="App">
<h1>Sample app testing dynamic backend</h1>
<div>Loaded backend host: {this.props.`apiHost`} </div>
</div>
);
}
}
export default App;
Also during Hydration is the apiHost
required. During the render step on the server it will become clear why the variable __API_HOST__
is a global on the window object.
// -- src/index.js
import React from "react";
import { render, hydrate } from "react-dom";
import "./index.css";
import App from "./App";
const apiHost = window.__API_HOST__;
const root = document.getElementById("root");
/**
* If childNodes exist, then server rendering
* happened and we only need to hydrate
*/
root.hasChildNodes()
? hydrate(<App apiHost={apiHost} />, root)
: render(<App apiHost={apiHost} />, root);
Server side rendering
To setup server side rendering (SSR), create a folder named server
in the root folder of the project. The render is divided into three files.
- server.js (including required files for JSX and ES6 imports)
- router.js (routing to direct network requests)
- renderer.js (actually render the app on the server and respond to the client)
Server.js
// -- server/server.js
/**
* (pre)Load the necessary libraries to ensure
* the react app can be built on the server.
* -- Normally these are loaded by webpack --
*/
require("ignore-styles");
require("url-loader");
require("file-loader");
require("@babel/polyfill");
require("@babel/register")({
presets: ["@babel/preset-env", "@babel/preset-react"],
plugins: [
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-proposal-export-default-from",
],
});
/** --- Now load the application as normal --- */
const express = require("express");
const router = require("./router");
// Initiate App
const app = express();
const { PORT = 3000 } = process.env; // default to 3000
/**
* Tell the app to use the router imported above
*/
app.use(router);
// start the express server and log what port it's running on
app.listen(PORT, () => console.log(`running on http://localhost:${PORT}`));
Router.js
Next add the router.js file.
// -- server/router.js
// -- server/router.js
const express = require("express");
const path = require("path");
const serverRenderer = require("./serverRenderer");
/**
* Create the router object
*/
const router = express.Router();
/**
* this route will not be used if server side rendering
* works properly.
* However getting this route will require an additional
* request to be sent to the server.
*/
router.get("/environment.json", ({}, response) => {
response.json({
apiHost: "http://somehost.com",
});
});
// root (/) should always serve our server rendered page
// serverRenderer will be discussed in the next section.
router.use("^/$", serverRenderer());
// Static assets should just be accessible
router.use(
express.static(path.resolve(__dirname, "..", "build"), {
maxAge: "30d",
})
);
/**
* Ensures the front-end source and assets are still
* found if the user refreshes the page on a deep route:
* /book/com.mytoori.book.sample4
* If this route is not here the front-end assets are not found
*/
router.use("^(?!api$)", serverRenderer());
module.exports = router;
Renderer.js
Before the app is rendered, read the API_HOST
environment variable from process.env
.
As part of the rendering step the API_HOST
is provided as a prop.
The variable is added to the HTML file so it can be picked up on the client and used during the Hydration step.
// -- server/renderer.js
const React = require("react");
const { renderToString } = require("react-dom/server"); // renders react app to string
const fs = require("fs");
const path = require("path");
const { API_HOST = "http://fallbackapihost.com" } = process.env;
import App from "../src/App"; // import the app that is going to be rendered on the server
const indexFilePath = path.resolve(__dirname, "..", "build", "index.html");
const serverRenderer =
() =>
async ({}, response) => {
// read the public/index.html file into memory
// The rendered html string is then inserted
// and sent to the client
fs.readFile(indexFilePath, "utf8", (err, indexHTMLFile) => {
if (err) {
console.error("Failed to read index.html ", err);
return response.status(404).end();
}
// Render the entire React app to HTML string
const renderedHTML = renderToString(<App apiHost={API_HOST} />);
// Add (Global) variable with data to
// the client window object
const initialData = `
<script>
window.__API_HOST__ = "${API_HOST}"
</script>
`;
return response.send(
indexHTMLFile.replace(
'<div id="root"></div>',
`<div id="root">${renderedHTML}</div>${initialData}`
)
);
});
};
module.exports = serverRenderer;
Running in Docker
The entire project can also be executed in a docker container. Copy the following to run the project in docker. It will build the project and run the start command when finished to start the project.
# Use the following image to build this docker image
# This is pulled from docker.com and has everything
# needed to run a node project
FROM node:alpine
# Back_env is set during build
# telling the front-end which back-end
# it should be talking to
# ARG backend_env
# ENV BACKEND_ENV $backend_env
ENV PORT 3000
# Navigate (cd) to the app folder in the docker container
WORKDIR /usr/src/app
# Copy all package.json / package-lock.json etc. to the root folder
# this is executed on build: docker build .
COPY ./package*.json ./
RUN npm install
# copy everything from the external directory to the container folder in docker
COPY . .
# build the front-end with react build scripts and store them in the build folder
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "start:prod"]
starting up
The start:prod command initiates the node process with the server/server.js file.
"start:prod": "NODE_ENV=production node ./server/server.js",
To test outside of Docker, simply run npm run start:prod
from the terminal.
While running the docker container the API host is provided as an environment variable. That is then read when rendering the app on the server.
# build docker
docker build -t mydockercontainer .
# run docker container with environment variable
docker run -e "API_HOST=https://dockerruntimeapihost.com" -p 3000:3000 mydockercontainer