Building a React + Express app into a Docker container

The steps involved with shoving a server and client into a single container image.

When I set out to build my latest Saas, I didn't want to do serverless, and I definitely didn't want to do SSR.

Instead, I wanted to use a stack I hadn't used in quite some time: React + Express. I started by trying to hook Vite into Express to run them simultaneously, but it proved to be problematic when it came to building the deployable artifacts. So I decided to split them up into separate apps: one directory containing a React app, and the other an Express server.

This article is about how to package the two projects together into a single, runnable Docker container.

The folder structure

I love working with monorepos as much as possible, and this was no exception.

I have a single repository with all of my different codebases as it relates to this product. The app directory contains all of the source for my React app. The server directory contains the Express app written with TypeScript.

Here is what that looks like in VSCode:

Building the code

Since the server is built with TypeScript, I need to transpile the code into runnable JavaScript. Running tsc from the terminal is enough to build the code. My tsconfig.json file specified that the output should be placed into the dist directory of the server project.

Here is what that file looks like:

{
  "compilerOptions": {
    "module": "commonjs",
    "esModuleInterop": true,
    "target": "es6",
    "noImplicitAny": false,
    "skipLibCheck": true,
    "moduleResolution": "node",
    "sourceMap": true,
    "outDir": "dist",
    "baseUrl": ".",
    "paths": {
      "*": [
        "node_modules/*"
      ]
    }
  },
  "include": [
    "src/**/*"
  ]
}

The React app is bootstrapped and run with Vite, but is also using TypeScript. Luckily the build script for this is preconfigured when you set up a React app with Vite, so simply calling npm run build will transpile the code into the dist directory of the App project folder.

Building the container

Even though I built both projects, the way they actually execute is different. The “compiled” Express code is just run with node on a given server, whereas the output of the React project is simply served up from a server and run on the client's browser. Luckily Express will handle that for me as long as I have it configured properly. In the index.ts file which is the entrypoint of my Express app, I have this snippet near the bottom:

const frontendFiles = process.cwd() + '/dist/dist'
app.use(express.static(frontendFiles))
app.get('/*', (_, res) => {
  res.sendFile(frontendFiles + '/index.html')
})

This means that when node starts the app, it will look for files in the dist/dist directory and server up index.html, which is the entrypoint of the compiled React code. The double dists will make sense in a moment.

Let’s take the dockerfile section by section. The first bit dictates that this will be using Node v18 as the base container image. I’m also setting up a directory to work with called /build. This will be a temporary folder just to build stuff in.

FROM node:18
WORKDIR /build

Next, I’m going to copy the source files for the React front end into /build, change my working directory to the React source, install dependencies, and build the project.

COPY app /build/app
WORKDIR /build/app
RUN npm install
RUN npm run build

Now I’ll do the same exact thing for the Express code.

COPY server /build/server
WORKDIR /build/server
RUN npm install
RUN npm run build

With both projects built, I can copy all of the necessary files into a folder called /run and clear out my /build directory since its no longer needed. Notice how the last cp line copies dist from the app build into dist from the server build (thus dist/dist). It's not elegant, but it works 😀

WORKDIR /run
RUN cp -r /build/server/dist /run
RUN cp -r /build/server/node_modules /run/dist
RUN cp -r /build/app/dist /run/dist

RUN rm -rf /build

Finally, run the entrypoint of the built Express server.

ENTRYPOINT [ "node", "/run/dist/index.js" ]

Here is the entire dockerfile put together:

FROM node:18

WORKDIR /build

COPY app /build/app
WORKDIR /build/app
RUN npm install
RUN npm run build

COPY server /build/server
WORKDIR /build/server
RUN npm install
RUN npm run build

WORKDIR /run
RUN cp -r /build/server/dist /run
RUN cp -r /build/server/node_modules /run/dist
RUN cp -r /build/app/dist /run/dist

RUN rm -rf /build

ENTRYPOINT [ "node", "/run/dist/index.js" ]

To build this entire project, I can run the following command from the root of my repository:

docker build . -t spacecharter:latest

And once the container image is built, I can run it with the following:

docker run --name spacecharter --env-file ./.env -p 8080:8080 spacecharter:latest