Deploy a React app in kuberentes

Posted on 2021-04-01 in Trucs et astuces

I recently deployed a React app in kubernetes. It was initially deployed directly in a public bucket: I have an API that is already hosted on kubernetes, but the app itself is made only of static files, so it made sense. However, requirements for my app changed and I required to add basic authentication to it, so before accessing the app the browser would ask for a username and a password. The main idea being to prevent users to access our pre-production app and to mistake it for our production one should a URL leak.

Since we are limited in what we can do with HTTP headers with GCP buckets, I was stuck. I also wanted to add extra HTTP headers like X-Frame-Options or X-Content-Type-Options which are useful for security reasons.

To do this, we can:

  • Insert a nginx between the user and the bucket. The nginx server would then act as a reverse proxy for the bucket. This would add extra round-trips to get the files and after some tests, it's hard to configure nginx correctly in this case.
  • Just put everything into a container and host the result on kubernetes alongside the API. We avoid round trips, and we have a complex but standard nginx configuration for SPAs. This configuration will be more simple and more tested than our weird "bucket proxy" one. Since the built JS, CSS and image files are small, we will get a small Docker image we can easily deploy.

To achieve this, I used the nginx configuration below, stored in a ConfigMap and deployed with Helm:

 1 apiVersion: v1
 2 kind: ConfigMap
 3 metadata:
 4   name: frontend-app-nginx
 5 data:
 6   website.conf: |
 7     server {
 8         listen 80;
 9         root /var/www/frontend-app/;
10         client_max_body_size 1G;
11 
12         gzip on;
13         index index.html;
14 
15         access_log stdout;
16         error_log  stderr;
17 
18         location / {
19             {{ if .Values.container.nginx.enableBasicAuth -}}
20             auth_basic           "Pre-Production. Access Restricted";
21             auth_basic_user_file /etc/nginx/conf.d/.htpasswd;
22             {{- end }}
23 
24             add_header X-Frame-Options DENY;
25             add_header X-XSS-Protection "1; mode=block";
26             add_header X-Content-Type-Options nosniff;
27 
28             # Set a very long cache on scripts and CSS since their URL contains
29             # their hash making them immutable.
30             # We can keep them for as long as we want.
31             # All those files are in a /static folder.
32             location /static/ {
33                 add_header Cache-Control "public, max-age=31536000, immutable";
34                 try_files $uri /$uri =404;
35             }
36 
37             # Set some cache on unhashed files (that's everything that ends with an extension).
38             location ~ .*\.[a-z]+$ {
39                 add_header Cache-Control "public, max-age=3600";
40                 try_files $uri /$uri =404;
41             }
42 
43             location /nghealth {
44                 {{ if .Values.container.nginx.enableBasicAuth -}}
45                 # Always disable basic authentication for health routes.
46                 # It makes working with them easier in kubernetes and for load balancers.
47                 auth_basic off;
48                 {{- end }}
49                 return 200;
50             }
51 
52             # If path doesn't exist, the user wants to reach the site.
53             # So we return the default index.
54             add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
55             try_files /$uri /index.html =404;
56         }
57     }
58   # Put the password here: it's base64 encoded so no directly readable and it exists first and foremost
59   # to signal users they don't have anything to do here, not to protect access from anything sensitive.
60   .htpasswd: <copy your .htpasswd here>

Note

You should always disable authentication for your health routes. You can configure probes to send HTTP headers to make it work, but I encountered issues in my setup where the load balancer can talk directly to the pod and required a health route too. It didn't seem to work when it was protected with basic authentication.

The deployment.yaml file:

 1 apiVersion: apps/v1
 2 kind: Deployment
 3 metadata:
 4   name: {{ include "chart.fullname" . }}
 5   labels:
 6 {{ include "chart.labels" . | indent 4 }}
 7 spec:
 8   selector:
 9     matchLabels:
10       app.kubernetes.io/name: {{ include "chart.name" . }}
11       app.kubernetes.io/instance: {{ .Release.Name }}
12   template:
13     metadata:
14       labels:
15         app.kubernetes.io/name: {{ include "chart.name" . }}
16         app.kubernetes.io/instance: {{ .Release.Name }}
17     spec:
18       containers:
19         - name: {{ .Chart.Name }}
20           image: "{{ .Values.container.image.repository }}:{{ .Values.container.image.tag }}"
21           imagePullPolicy: {{ .Values.container.image.pullPolicy }}
22           ports:
23             - name: http
24               containerPort: {{ .Values.container.port }}
25               protocol: TCP
26           resources:
27             limits:
28               memory: {{ .Values.container.resources.limits.memory }}
29               cpu: {{ .Values.container.resources.limits.cpu }}
30             requests:
31               memory: {{ .Values.container.resources.requests.memory }}
32               cpu: {{ .Values.container.resources.requests.cpu }}
33           {{ if .Values.container.probe.enabled -}}
34           livenessProbe:
35             httpGet:
36               path: {{ .Values.container.probe.path }}
37               port: {{ .Values.container.port }}
38             timeoutSeconds: {{ .Values.container.probe.livenessTimeOut }}
39             initialDelaySeconds: {{ .Values.container.probe.initialDelaySeconds }}
40           readinessProbe:
41             httpGet:
42               path: {{ .Values.container.probe.path }}
43               port: {{ .Values.container.port }}
44             timeoutSeconds: {{ .Values.container.probe.livenessTimeOut }}
45             initialDelaySeconds: {{ .Values.container.probe.initialDelaySeconds }}
46           {{- end }}
47           volumeMounts:
48             - name: nginx-conf
49               mountPath: /etc/nginx/conf.d
50               readOnly: true
51       volumes:
52         - name: nginx-conf
53           configMap:
54             name: frontend-app-nginx

And my Dockerfile which relies on multi-stage builds to reduce the size of the target image. I use a node image to build the files and then only a standard nginx image to serve them:

FROM node:14.16.0-alpine3.13 AS builder
WORKDIR /app

RUN apk add python3 make gcc g++ libc-dev

ARG REACT_APP_ENV=undefined

ENV REACT_APP_ENV=$REACT_APP_ENV

COPY . ./

RUN yarn install --frozen-lockfile
RUN yarn build && \
    find -name \*.js\*.map -delete


# Run the app in nginx.
FROM nginx:latest AS runner
RUN mkdir -p /var/www/frontend-app
WORKDIR /var/www/frontend-app

COPY --from=builder /app/build /var/www/frontend-app/

I pass the necessary arguments to docker so it can build the image with something like --build-arg REACT_APP_ENV=$REACT_APP_ENV. I then capture it with the ARG command in the Dockerfile and transform it into an environment variable react-app can use.

With all this, you should be able to have a working nginx for your SPA if you need it. There are some consequences to doing it this way though:

  • Nginx will respond with a 200 status and the index.html when a 404 would be more appropriate. Sadly, this cannot be solved in a simple manner, and we also have this issue with the bucket. So on that problem, both solutions are even.
  • You gain more flexibility over what you can/cannot do with headers and authentication, which is very good.
  • You should be able to keep old versions of static files to prevent issues when we switch from one version of the app to another. I hope I'll be able to make it work soon. If I do, I'll post a link here.
  • If we need big files (eg videos, many images…), we will need to put them into a bucket to keep the image small. For instance, if you have big images or videos. Since those files shouldn't change often (and most likely not at every build), managing them differently shouldn't be an issue.

Note

To keep things simple, I don't recommend you deploy a SPA into kubernetes unless you need the extra flexibility a dedicated nginx will give you. Bucket are bare bones, but simple and reliable. Use them for as long as you can.