NOTE! This article is still just a draft version.
We will be using a TypeScript frontend app which was created by react-create-app
.
First we’ll setup a Dockerfile for building a static image for the frontend. We’ll do this using
the node:14-slim
image, since Nginx image doesn’t contain a build system.
Then we’ll use that image in the Nginx container’s Dockerfile and configure our Nginx docker image so that it can support runtime environment variables.
Finally we’ll use docker-compose
to spin up our complete setup.
This frontend image is used only for building the compiled and minified frontend image. It will never be started as a container.
First we’ll define build argument NODE_IMAGE
. It enables to customize the base image used for
building the image at build time.
We’ll default it to node:14-slim
:
ARG NODE_IMAGE=node:14-slim
FROM $NODE_IMAGE as node-image
FROM node-image
Then we’ll define default values as build arguments so that it’s possible to change defaults while building the image.
ARG DEFAULT_NODE_ENV=production
ARG DEFAULT_GENERATE_SOURCEMAP=false
ARG DEFAULT_API_URL=/api
Any frontend app created using create-react-app
will recognize and pass on NODE_ENV
and
REACT_APP_*
variables to the generated bundle.
The GENERATE_SOURCEMAP
is also supported. It controls if source maps are generated or not.
ENV NODE_ENV=$DEFAULT_NODE_ENV
ENV GENERATE_SOURCEMAP=$DEFAULT_GENERATE_SOURCEMAP
ENV REACT_APP_API_URL=$DEFAULT_API_URL
You can use these variables in your bundle using process.env.NODE_ENV
, etc. I usually create a
file like frontend/src/environment.ts
where I parse and export these to other parts of the frontend.
We’ll set the work directory as /app
and copy package*.json files there first before installing dependencies.
WORKDIR /app
COPY ./package*.json ./
RUN npm ci --silent
This style enables Docker to use layers correctly.
Then we’ll copy our TypeScript configuration and rest of the source code:
COPY tsconfig.json ./tsconfig.json
COPY public ./public
COPY src ./src
Finally we’ll build the source code:
RUN npm run build
Here’s our full finished frontend/Dockerfile
:
ARG NODE_IMAGE=node:14-slim
FROM $NODE_IMAGE as node-image
FROM node-image
ARG DEFAULT_NODE_ENV=production
ARG DEFAULT_GENERATE_SOURCEMAP=false
ARG DEFAULT_API_URL=/api
ENV NODE_ENV=$DEFAULT_NODE_ENV
ENV GENERATE_SOURCEMAP=$DEFAULT_GENERATE_SOURCEMAP
ENV REACT_APP_API_URL=$DEFAULT_API_URL
WORKDIR /app
COPY ./package*.json ./
RUN npm ci --silent
COPY tsconfig.json ./tsconfig.json
COPY public ./public
COPY src ./src
RUN npm run build
We’ll use Nginx to host our frontend and route traffic to our backend.
Just like we did for the frontend image, we will define build-time options to dynamically change which images we use as a base.
ARG NGINX_IMAGE=nginx:alpine
ARG FRONTEND_IMAGE=my-frontend
FROM $FRONTEND_IMAGE as frontend-image
FROM $NGINX_IMAGE as nginx-image
FROM nginx-image
While debugging, you can change NGINX_IMAGE
to nginx:latest
if you want to debug Nginx inside
the container using a shell executable. Debugging with the default nginx:alpine
image is much
harder since it does not include a shell.
With the option FRONTEND_IMAGE
you can customize the name of the image from where to fetch the
compiled static frontend code.
Just like with our NodeJS container, we can define some environment variables.
ARG DEFAULT_PORT=8080
ARG DEFAULT_API_URL=https://api.example.com
ENV NGINX_PORT=$DEFAULT_PORT
ENV NGINX_API_URL=$DEFAULT_API_URL
We’ll use NGINX_PORT
to define the port where the Nginx listens for requests and NGINX_API_URL
as the URL where to route traffic for our /api
end point.
Now we’ll define rule to copy our templates folder (we’ll create it later):
COPY templates /etc/nginx/templates
Finally we copy our static frontend code from frontend-image
’s /app/build
path to Nginx’s public document root:
COPY --from=frontend-image /app/build /usr/share/nginx/html
Here’s the full Nginx Dockerfile:
ARG NGINX_IMAGE=nginx:alpine
ARG FRONTEND_IMAGE=my-frontend
FROM $FRONTEND_IMAGE as frontend-image
FROM $NGINX_IMAGE as nginx-image
FROM nginx-image
ARG DEFAULT_PORT=8080
ARG DEFAULT_API_URL=https://api.example.com
ENV NGINX_PORT=$DEFAULT_PORT
ENV NGINX_API_URL=$DEFAULT_API_URL
COPY templates /etc/nginx/templates
COPY --from=frontend-image /app/build /usr/share/nginx/html
Now, we’ll create a folder named templates
and a file
./templates/10-default.conf.template
.
The file will look like:
server {
listen ${NGINX_PORT};
listen [::]:${NGINX_PORT};
root /usr/share/nginx/html;
index index.html;
server_name _;
location /api/ {
proxy_pass ${NGINX_API_URL}/;
proxy_set_header Host $proxy_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffers 16 4k;
proxy_buffer_size 2k;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location / {
try_files $uri $uri/ =404;
}
}
The Nginx container will read this file and change our environment variables automatically using
envsubst
when ever our container starts up.
See also my previous Hosting web apps article for meaning of rest of the syntax.
We also defined a proxy from the end point /api/*
to our backend at configurable address NGINX_API_URL/*
:
location /api/ {
proxy_pass ${NGINX_API_URL}/;
proxy_set_header Host $proxy_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffers 16 4k;
proxy_buffer_size 2k;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
This setup enables support for websockets and also makes it possible to run it behind other load
balancers by defining X-Forwarded-For
using the $proxy_add_x_forwarded_for
. This is a special
variable which will be set as X-Forwarded-For
header, if it exists, otherwise it will be
$remote_addr
.
Finally we can use docker-compose
to spin our setup up for development.
Create a file named docker-compose.yml
:
version: "3.9"
services:
my-frontend-builder:
container_name: my-frontend-builder
image: "my-frontend-builder:latest"
build:
context: ./frontend
dockerfile: Dockerfile
args:
DEFAULT_NODE_ENV: "development"
DEFAULT_GENERATE_SOURCEMAP: "true"
DEFAULT_API_URL: "/api"
entrypoint: "sh -c"
command: "exit 0"
restart: "no"
my-backend:
container_name: my-backend
image: "my-backend:latest"
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8081:8081"
environment:
PORT: 8081
my-nginx:
container_name: my-nginx
image: "my-nginx:latest"
build:
context: .
dockerfile: Dockerfile
args:
FRONTEND_IMAGE: 'my-frontend-builder:latest'
ports:
- "8080:8080"
depends_on:
- my-frontend-builder
- my-backend
environment:
NGINX_PORT: 8080
NGINX_API_URL: "http://my-backend:8081"
This configuration also includes my-backend
which we didn’t create. We assume you have a backend container
already available in folder ./backend
.
Now we can build our three containers using a command docker-compose build
.
And finally start them using docker-compose up
.
While developing the frontend, you don’t actually need Nginx, since react-create-app
comes with a
proxy setup for development purposes.
Just install package http-proxy-middleware
:
npm install http-proxy-middleware --save
…and create a file frontend/src/setupProxy.js
:
const { createProxyMiddleware } = require('http-proxy-middleware');
const REACT_APP_BACKEND_API_URL = process.env.REACT_APP_BACKEND_API_URL ? process.env.REACT_APP_BACKEND_API_URL : 'https://localhost:8081';
module.exports = function(app) {
app.use(
'/api',
createProxyMiddleware({
target: REACT_APP_BACKEND_API_URL,
changeOrigin: true,
autoRewrite: true,
protocolRewrite: 'http',
pathRewrite: {
['^/api'] : ''
},
})
);
};
Then you can set the environment variable REACT_APP_BACKEND_API_URL
to point to your backend’s URL, and start the frontend development server:
cd frontend
REACT_APP_BACKEND_API_URL='https://api.example.com' npm start
Now any request to http://localhost:3000/api
will be redirected to https://api.example.com
.